mirror of
https://github.com/ValueCell-ai/ClawX.git
synced 2026-08-14 17:02:22 +00:00
Compare commits
392
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bc9d2740e | ||
|
|
f058ccf28f | ||
|
|
2a965cb462 | ||
|
|
9fe334dec2 | ||
|
|
8afad35d2f | ||
|
|
92f966424f | ||
|
|
a378c31261 | ||
|
|
960f6b298d | ||
|
|
8034c5ad31 | ||
|
|
1f26cd205d | ||
|
|
9cdd501a80 | ||
|
|
26d20fdb35 | ||
|
|
378e01ee1e | ||
|
|
7cb6eb240c | ||
|
|
cd0d95ad69 | ||
|
|
d501bad05d | ||
|
|
5d0099abaa | ||
|
|
951ca13db8 | ||
|
|
c96b2336c6 | ||
|
|
a42a7e4256 | ||
|
|
ff61f5dd72 | ||
|
|
91836cd6bc | ||
|
|
fb19cc61c5 | ||
|
|
0fa869f1cc | ||
|
|
33bcf654eb | ||
|
|
7774d89c66 | ||
|
|
d741364c74 | ||
|
|
502d9d5367 | ||
|
|
1e52b0048a | ||
|
|
fc87e00323 | ||
|
|
a9d9376fe0 | ||
|
|
1a46e1f1d5 | ||
|
|
51655b91e1 | ||
|
|
702f03e055 | ||
|
|
c7d4345b6e | ||
|
|
b598e2482d | ||
|
|
3d134a563a | ||
|
|
ff9024fb5d | ||
|
|
4a74b22796 | ||
|
|
bcec47f600 | ||
|
|
d7671a42e6 | ||
|
|
f44c2250fb | ||
|
|
d6c770e4a5 | ||
|
|
e84f13c876 | ||
|
|
a0509b9b54 | ||
|
|
e09dd289db | ||
|
|
2a1e2adde3 | ||
|
|
6ea53bdeb1 | ||
|
|
f3689894bf | ||
|
|
33694efe84 | ||
|
|
22af7468d7 | ||
|
|
cb8c3cfd02 | ||
|
|
e42307cbd3 | ||
|
|
ffe50ff8a5 | ||
|
|
d223af5747 | ||
|
|
b0db926874 | ||
|
|
05ea50a834 | ||
|
|
808f4a840d | ||
|
|
d9ffbca856 | ||
|
|
fcfb72b103 | ||
|
|
ae91ba3c05 | ||
|
|
dd92cf400b | ||
|
|
d246860ef1 | ||
|
|
66692f1777 | ||
|
|
1fd3c50c3e | ||
|
|
bf038f5ca4 | ||
|
|
07c5c4cfcc | ||
|
|
71ea96a881 | ||
|
|
1e2fc7e404 | ||
|
|
65a8a906a6 | ||
|
|
581981f203 | ||
|
|
48d80bf660 | ||
|
|
277c21c91a | ||
|
|
580b213030 | ||
|
|
9e531d1229 | ||
|
|
8ea306d91c | ||
|
|
840fe62182 | ||
|
|
7435e9ce29 | ||
|
|
e47fce4ae4 | ||
|
|
d27faa5a00 | ||
|
|
83317210f6 | ||
|
|
e91b6774d4 | ||
|
|
aa7f1f72fc | ||
|
|
c25c7fc432 | ||
|
|
73e68f769d | ||
|
|
55ab4d5a7c | ||
|
|
c16b428651 | ||
|
|
6e305e6dba | ||
|
|
97dca873a1 | ||
|
|
5b0c1c9909 | ||
|
|
a87a86df50 | ||
|
|
1830bbe787 | ||
|
|
800932507b | ||
|
|
29de7e15c2 | ||
|
|
e97af800b3 | ||
|
|
1e64cf2230 | ||
|
|
be7f8fc768 | ||
|
|
39c8400421 | ||
|
|
b7d0ca0c53 | ||
|
|
8635f0f5eb | ||
|
|
e79a4f923e | ||
|
|
d15e2330d5 | ||
|
|
219db5ff78 | ||
|
|
21c1ba2e33 | ||
|
|
d596ed5b50 | ||
|
|
0da6a24f55 | ||
|
|
3659179fc9 | ||
|
|
94de6fb08e | ||
|
|
3178f96c55 | ||
|
|
b4588c5aba | ||
|
|
f474bd41ac | ||
|
|
fb2d9e312a | ||
|
|
0fb460efca | ||
|
|
d9f42930d7 | ||
|
|
2f7466e2dd | ||
|
|
dbe9d3c9bb | ||
|
|
1aa4776ea5 | ||
|
|
0d5323360c | ||
|
|
3de1fed446 | ||
|
|
035ee8e32c | ||
|
|
4136b04c42 | ||
|
|
566a2d752d | ||
|
|
a417d4ec6b | ||
|
|
8af070a7a0 | ||
|
|
8df7eee0c1 | ||
|
|
f4d6478f1c | ||
|
|
fdc0b0d746 | ||
|
|
dea38c0c44 | ||
|
|
0ee8f5e202 | ||
|
|
60b5209cf6 | ||
|
|
3edeb8cdfa | ||
|
|
34bfae2851 | ||
|
|
360b6649ba | ||
|
|
75343d58d1 | ||
|
|
160160ecde | ||
|
|
80251a5825 | ||
|
|
b0e55692f5 | ||
|
|
ed1525e25b | ||
|
|
4865afc6e6 | ||
|
|
42d62f5002 | ||
|
|
62c11d2441 | ||
|
|
58aa88b3ae | ||
|
|
2327f0ba6a | ||
|
|
7dc18bb6e7 | ||
|
|
5c1ed5e6ee | ||
|
|
abce8a8b96 | ||
|
|
087b38a39a | ||
|
|
cbf70fb299 | ||
|
|
8a8cd73919 | ||
|
|
a7f4552be6 | ||
|
|
c1ba38c71b | ||
|
|
294c88f739 | ||
|
|
51b86bdded | ||
|
|
15e8fe8e05 | ||
|
|
c6adf36b57 | ||
|
|
05eb54edb6 | ||
|
|
50e88f6789 | ||
|
|
8ba82e23f9 | ||
|
|
4e4ebd1092 | ||
|
|
35ae535646 | ||
|
|
fc13237023 | ||
|
|
9ada49afb3 | ||
|
|
0c8dc8cb72 | ||
|
|
59579e0852 | ||
|
|
654468b26c | ||
|
|
e516394580 | ||
|
|
5bc994ada7 | ||
|
|
5c2e689cb4 | ||
|
|
0c10c729ee | ||
|
|
0f0ec50e91 | ||
|
|
8c9b4ea670 | ||
|
|
d47101ab4c | ||
|
|
cc7992e160 | ||
|
|
62d83b401f | ||
|
|
110b473080 | ||
|
|
e6c18022a5 | ||
|
|
e545fa2194 | ||
|
|
d18985712c | ||
|
|
f88d6bc17b | ||
|
|
1fc1e20ec4 | ||
|
|
54b75ded19 | ||
|
|
a74db571d6 | ||
|
|
5c97044865 | ||
|
|
9d3a59dc0e | ||
|
|
91c6a26115 | ||
|
|
ebeb2893a1 | ||
|
|
9c930b2198 | ||
|
|
e95c72f79a | ||
|
|
f9361e686a | ||
|
|
ceb537f7bd | ||
|
|
04e234bbde | ||
|
|
9a360eb43f | ||
|
|
89dd765fd6 | ||
|
|
4271419abb | ||
|
|
01fd010a0e | ||
|
|
f893f2b21b | ||
|
|
ae9af725b2 | ||
|
|
42a26c41a2 | ||
|
|
31d4531327 | ||
|
|
430d1d0603 | ||
|
|
5137e706c9 | ||
|
|
4b14f977fa | ||
|
|
1b75fec71c | ||
|
|
956e943072 | ||
|
|
d0c79f6a0a | ||
|
|
eda34ad9ce | ||
|
|
c29ff4dd33 | ||
|
|
36282fce35 | ||
|
|
285f8202c7 | ||
|
|
1ed9f77a29 | ||
|
|
92144ab639 | ||
|
|
9a1575114d | ||
|
|
7fa4852c1d | ||
|
|
1b2dccee6e | ||
|
|
2f03aa1fad | ||
|
|
3a424ef692 | ||
|
|
24b43335f8 | ||
|
|
6d67a77633 | ||
|
|
4ac4e12aad | ||
|
|
48415db990 | ||
|
|
396e9e0b03 | ||
|
|
b884db629e | ||
|
|
2fefbf3aba | ||
|
|
1f39d1a8a7 | ||
|
|
6acd8acf5a | ||
|
|
a20a97ad83 | ||
|
|
30bd8c08f9 | ||
|
|
758a8f8c94 | ||
|
|
54ec784545 | ||
|
|
03c40985e1 | ||
|
|
5482acd43d | ||
|
|
b2c478d554 | ||
|
|
4ff6861042 | ||
|
|
87ab12849c | ||
|
|
49518300dc | ||
|
|
66b2ddb2dc | ||
|
|
fc9e37c4a3 | ||
|
|
467fcf7e92 | ||
|
|
96c9f6fe5b | ||
|
|
d03902dd4d | ||
|
|
c1e165d48d | ||
|
|
19b3ea974b | ||
|
|
3b629e8db4 | ||
|
|
0b3152f7f2 | ||
|
|
25b13ab912 | ||
|
|
32d14b8cf9 | ||
|
|
3021ad5089 | ||
|
|
97d29ab23c | ||
|
|
c3a735a49c | ||
|
|
d8750e135b | ||
|
|
413244522e | ||
|
|
0cdd12cd40 | ||
|
|
c866205eac | ||
|
|
91c735c9f4 | ||
|
|
e640316382 | ||
|
|
83f67e1ed3 | ||
|
|
1d2cbf8f26 | ||
|
|
42d6cec1e0 | ||
|
|
28508bc643 | ||
|
|
560ae95611 | ||
|
|
d3112f28d8 | ||
|
|
b9fd5f6a78 | ||
|
|
fa2131ab13 | ||
|
|
c14bdfa204 | ||
|
|
7e2c4d3835 | ||
|
|
06266cb4d2 | ||
|
|
5a3da41562 | ||
|
|
34bbb039d3 | ||
|
|
ca92d7fa2c | ||
|
|
d34a88e629 | ||
|
|
bf5b089158 | ||
|
|
eb948d5820 | ||
|
|
ec8db0be75 | ||
|
|
aa2e4eae14 | ||
|
|
e988258c59 | ||
|
|
ef3cf64484 | ||
|
|
47c9560b9e | ||
|
|
91a86a4a76 | ||
|
|
870abb99c4 | ||
|
|
2668082809 | ||
|
|
514a6c4112 | ||
|
|
9b56d80d22 | ||
|
|
07f3c310b5 | ||
|
|
15a3faa996 | ||
|
|
77d6879b16 | ||
|
|
50bcfeccfc | ||
|
|
1292e9f120 | ||
|
|
aa98e59317 | ||
|
|
537a85c4d1 | ||
|
|
321a371677 | ||
|
|
fcf5f7c566 | ||
|
|
19a6d2dd34 | ||
|
|
bed62c64e8 | ||
|
|
992eda8fe0 | ||
|
|
ba5947e2cb | ||
|
|
b786b773f1 | ||
|
|
83858fdf73 | ||
|
|
8c3a6a5f7a | ||
|
|
ab8fe760ef | ||
|
|
9d40e1fa05 | ||
|
|
9aea3c9441 | ||
|
|
05ae404dee | ||
|
|
859e3fd6c5 | ||
|
|
c6021cedf4 | ||
|
|
6b82c6ccb4 | ||
|
|
884aa7c7f1 | ||
|
|
7643cb8a75 | ||
|
|
4d75dc1e5f | ||
|
|
7b1e79ed7b | ||
|
|
f56cd5cc1d | ||
|
|
ae2aa97775 | ||
|
|
2c816cf6ea | ||
|
|
c04e3b5464 | ||
|
|
1e7b40a486 | ||
|
|
f12f4a74df | ||
|
|
d39982bad8 | ||
|
|
f16e8062e1 | ||
|
|
56701d823c | ||
|
|
e10ff3a1fb | ||
|
|
5836ba6b13 | ||
|
|
5e519f9aa6 | ||
|
|
b71982c406 | ||
|
|
9b503b531b | ||
|
|
016ebb2b7b | ||
|
|
fff02a8164 | ||
|
|
04f4f67f5a | ||
|
|
b4fd4c85da | ||
|
|
a30f2238a4 | ||
|
|
0ff5c90bb8 | ||
|
|
2471228cc2 | ||
|
|
8029b507ba | ||
|
|
8cca9af773 | ||
|
|
78a9eb755b | ||
|
|
2253fed5a1 | ||
|
|
6b0400e3c3 | ||
|
|
73343d16ea | ||
|
|
1b527d2f49 | ||
|
|
7aec2febed | ||
|
|
d8d78ad690 | ||
|
|
f9164b3d15 | ||
|
|
dbc9972fb8 | ||
|
|
b27815b394 | ||
|
|
433240c43f | ||
|
|
58f19d4ddc | ||
|
|
4b4760bb12 | ||
|
|
2dc1070213 | ||
|
|
1eda50ef44 | ||
|
|
554f894493 | ||
|
|
5a657130c9 | ||
|
|
e94629e5c8 | ||
|
|
94f1404d01 | ||
|
|
b9231d6431 | ||
|
|
3b9ecb72b4 | ||
|
|
c86bacd240 | ||
|
|
686bc2f235 | ||
|
|
64d3539956 | ||
|
|
d4367d3265 | ||
|
|
43fe7a4d1c | ||
|
|
ca1a325993 | ||
|
|
11e28a2cfa | ||
|
|
925fbab86d | ||
|
|
a414c8e8a8 | ||
|
|
4be679ac56 | ||
|
|
db480dff17 | ||
|
|
f1e2e9fa01 | ||
|
|
7e54aad9e6 | ||
|
|
4e3f3c83f6 | ||
|
|
f6128ed743 | ||
|
|
6051a39a56 | ||
|
|
7fb9d6364d | ||
|
|
d35e8481b7 | ||
|
|
af81700180 | ||
|
|
0b15df25c9 | ||
|
|
75789b9947 | ||
|
|
9ec23174c0 | ||
|
|
02d38d15db | ||
|
|
e716c6a4ad | ||
|
|
dd0ce7740a | ||
|
|
158e84ce8f | ||
|
|
9e10c12f67 | ||
|
|
7f3408559d | ||
|
|
89bda3c7af | ||
|
|
1dbe4a8466 | ||
|
|
08960d700f | ||
|
|
04aa94f907 | ||
|
|
0cdafde2df | ||
|
|
315ff6b9de | ||
|
|
61291ff83f | ||
|
|
67ffe09dfc | ||
|
|
6fdcd35283 | ||
|
|
9f2bc3cf68 | ||
|
|
f6de56fa78 |
@@ -0,0 +1,38 @@
|
||||
# Local real cc-connect validation template.
|
||||
# Save real values in .env.cc-connect.local. That file is gitignored.
|
||||
|
||||
# Required for real OAuth, packaged OAuth, and Feishu E2E paths.
|
||||
# Point at the auth.json that may be copied into an isolated managed CODEX_HOME.
|
||||
# Use ~/.codex/auth.json only when that import is intentional.
|
||||
CLAWX_REAL_CODEX_AUTH_JSON=
|
||||
|
||||
# Required for OpenAI API-key provider/model chat validation.
|
||||
# The verifier maps this to child-process OPENAI_API_KEY without writing the value to reports.
|
||||
CLAWX_REAL_OPENAI_API_KEY=
|
||||
|
||||
# Optional: override the model used by the real OpenAI API-key smoke.
|
||||
# Leave empty to use the test default.
|
||||
CLAWX_REAL_OPENAI_MODEL=
|
||||
|
||||
# Optional: set OPENAI_API_KEY directly instead when external tools need the standard name.
|
||||
# OPENAI_API_KEY=
|
||||
|
||||
# Required for Feishu/Lark live channel lifecycle validation.
|
||||
CLAWX_REAL_FEISHU_APP_ID=
|
||||
CLAWX_REAL_FEISHU_APP_SECRET=
|
||||
# A real user/open_id accepted by the bot. The lifecycle test verifies that
|
||||
# cc-connect preserves this admin together with ClawX's local bridge admin.
|
||||
CLAWX_REAL_FEISHU_ADMIN_FROM=
|
||||
|
||||
# Optional Feishu/Lark settings.
|
||||
# Values for CLAWX_REAL_FEISHU_DOMAIN: feishu, lark, cn, global, or a full API base URL.
|
||||
CLAWX_REAL_FEISHU_DOMAIN=feishu
|
||||
CLAWX_REAL_FEISHU_ACCOUNT_ID=real_feishu_bot
|
||||
CLAWX_REAL_FEISHU_ALLOW_FROM=
|
||||
|
||||
# Optional manual Feishu/Lark inbound delivery smoke.
|
||||
# Set this only when a sandbox tenant chat can send the marker to the configured bot
|
||||
# while the E2E test is waiting.
|
||||
CLAWX_REAL_FEISHU_INBOUND_E2E=
|
||||
CLAWX_REAL_FEISHU_INBOUND_MARKER=
|
||||
CLAWX_REAL_FEISHU_INBOUND_TIMEOUT_MS=180000
|
||||
@@ -0,0 +1,76 @@
|
||||
name: Bug Report
|
||||
description: Report a reproducible problem in ClawX.
|
||||
title: "[Bug]: "
|
||||
labels:
|
||||
- bug
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for filing a bug report. Please provide enough detail to reproduce and verify a fix.
|
||||
|
||||
- type: textarea
|
||||
id: summary
|
||||
attributes:
|
||||
label: Summary
|
||||
description: Briefly describe the problem.
|
||||
placeholder: What happened?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: List exact steps to reproduce the issue.
|
||||
placeholder: |
|
||||
1. Go to ...
|
||||
2. Click ...
|
||||
3. See error ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
placeholder: What did you expect to happen?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual Behavior
|
||||
placeholder: What actually happened?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: Environment
|
||||
description: Share OS, app version/commit, and relevant runtime details.
|
||||
placeholder: |
|
||||
- OS:
|
||||
- ClawX version/commit:
|
||||
- Node/pnpm (if relevant):
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs or Screenshots
|
||||
description: Paste relevant logs, stack traces, or screenshots.
|
||||
render: shell
|
||||
|
||||
- type: checkboxes
|
||||
id: checks
|
||||
attributes:
|
||||
label: Pre-Submission Checklist
|
||||
options:
|
||||
- label: I searched existing issues and did not find a duplicate.
|
||||
required: true
|
||||
- label: I can reproduce this issue on the latest main branch build.
|
||||
required: false
|
||||
@@ -0,0 +1,2 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links: []
|
||||
@@ -0,0 +1,38 @@
|
||||
name: Documentation
|
||||
description: Report missing, unclear, or outdated documentation.
|
||||
title: "[Docs]: "
|
||||
labels:
|
||||
- documentation
|
||||
body:
|
||||
- type: textarea
|
||||
id: issue
|
||||
attributes:
|
||||
label: Documentation Issue
|
||||
description: What is missing, unclear, or incorrect?
|
||||
placeholder: The README section ... is outdated because ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: location
|
||||
attributes:
|
||||
label: Affected Location
|
||||
description: Which docs are impacted?
|
||||
placeholder: README.md / README.zh-CN.md / README.ja-JP.md / other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: proposal
|
||||
attributes:
|
||||
label: Suggested Update
|
||||
description: What should the docs say instead?
|
||||
placeholder: Replace ... with ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: references
|
||||
attributes:
|
||||
label: References
|
||||
description: Related issue/PR/commit links.
|
||||
@@ -0,0 +1,51 @@
|
||||
name: Feature Request
|
||||
description: Propose a new capability or improvement.
|
||||
title: "[Feature]: "
|
||||
labels:
|
||||
- enhancement
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for the suggestion. Clear problem and success criteria help reviewers evaluate quickly.
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem Statement
|
||||
description: What user problem are you trying to solve?
|
||||
placeholder: The current behavior is ... and it causes ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: proposal
|
||||
attributes:
|
||||
label: Proposed Solution
|
||||
description: Describe your preferred solution.
|
||||
placeholder: We could add ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives Considered
|
||||
description: Optional alternatives or tradeoffs.
|
||||
|
||||
- type: textarea
|
||||
id: success
|
||||
attributes:
|
||||
label: Success Criteria
|
||||
description: How should we know this is done?
|
||||
placeholder: |
|
||||
- [ ] ...
|
||||
- [ ] ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Mockups, references, related links, etc.
|
||||
@@ -0,0 +1,25 @@
|
||||
## Summary
|
||||
|
||||
<!-- What does this PR change and why? -->
|
||||
|
||||
## Related Issue(s)
|
||||
|
||||
<!-- e.g. Closes #123 -->
|
||||
|
||||
## Type of Change
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature
|
||||
- [ ] Documentation
|
||||
- [ ] Refactor
|
||||
- [ ] Other
|
||||
|
||||
## Validation
|
||||
|
||||
<!-- How did you verify this change? -->
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] I ran relevant checks/tests locally.
|
||||
- [ ] I updated docs if behavior or interfaces changed.
|
||||
- [ ] I verified there are no unrelated changes in this PR.
|
||||
@@ -25,9 +25,52 @@ jobs:
|
||||
node-version: '24'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Prefer HTTPS for public GitHub git dependencies
|
||||
run: |
|
||||
git config --global "url.https://github.com/.insteadOf" "git@github.com:"
|
||||
git config --global --add "url.https://github.com/.insteadOf" "ssh://git@github.com/"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# The runtime compatibility test executes Electron to assert its embedded
|
||||
# Node and SQLite versions, so this job cannot rely on the package alone.
|
||||
# Use the same extraction path as Electron E2E because install.js can
|
||||
# leave a partially extracted dist directory on GitHub-hosted runners.
|
||||
- name: Install Electron binary for runtime compatibility test
|
||||
shell: bash
|
||||
env:
|
||||
force_no_cache: 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
unset ELECTRON_SKIP_BINARY_DOWNLOAD
|
||||
ELECTRON_DIR="$(node -p "require('path').dirname(require.resolve('electron/package.json'))")"
|
||||
echo "Electron package dir: $ELECTRON_DIR"
|
||||
rm -rf "$ELECTRON_DIR/dist" "$ELECTRON_DIR/path.txt"
|
||||
mkdir -p "$ELECTRON_DIR/dist"
|
||||
|
||||
ZIP="$(cd "$ELECTRON_DIR" && node -e "
|
||||
const { downloadArtifact } = require('@electron/get');
|
||||
const { version } = require('./package.json');
|
||||
downloadArtifact({ version, artifactName: 'electron', force: true })
|
||||
.then((z) => { process.stdout.write(z); process.exit(0); })
|
||||
.catch((e) => { console.error(e); process.exit(1); });
|
||||
")"
|
||||
ZIP_SIZE="$(stat -c%s "$ZIP")"
|
||||
echo "Downloaded zip: $ZIP ($ZIP_SIZE bytes)"
|
||||
|
||||
unzip -oq "$ZIP" -d "$ELECTRON_DIR/dist"
|
||||
echo "Extracted top-level entries: $(ls -1 "$ELECTRON_DIR/dist" | wc -l | tr -d ' ')"
|
||||
if [ -f "$ELECTRON_DIR/dist/electron.d.ts" ]; then
|
||||
mv "$ELECTRON_DIR/dist/electron.d.ts" "$ELECTRON_DIR/electron.d.ts"
|
||||
fi
|
||||
test -f "$ELECTRON_DIR/dist/electron"
|
||||
chmod +x "$ELECTRON_DIR/dist/electron"
|
||||
printf '%s' 'electron' > "$ELECTRON_DIR/path.txt"
|
||||
|
||||
- name: Generate extension bridge
|
||||
run: pnpm run ext:bridge
|
||||
|
||||
- name: Run linter
|
||||
run: pnpm run lint
|
||||
|
||||
@@ -37,6 +80,18 @@ jobs:
|
||||
- name: Run tests
|
||||
run: pnpm run test
|
||||
|
||||
- name: Run harness checks
|
||||
run: pnpm run harness:ci
|
||||
|
||||
- name: Upload harness artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: harness-artifacts
|
||||
path: artifacts/harness
|
||||
if-no-files-found: warn
|
||||
retention-days: 7
|
||||
|
||||
build:
|
||||
runs-on: windows-latest
|
||||
env:
|
||||
@@ -54,8 +109,19 @@ jobs:
|
||||
node-version: '24'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Prefer HTTPS for public GitHub git dependencies
|
||||
run: |
|
||||
git config --global "url.https://github.com/.insteadOf" "git@github.com:"
|
||||
git config --global --add "url.https://github.com/.insteadOf" "ssh://git@github.com/"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Test Windows attachment open-with bridge
|
||||
run: pnpm exec vitest run tests/unit/attachment-open-with.test.ts tests/unit/attachment-open-with-native.test.ts
|
||||
|
||||
- name: Generate extension bridge
|
||||
run: pnpm run ext:bridge
|
||||
|
||||
- name: Build
|
||||
run: pnpm run build:vite
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
name: Comms Regression
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'src/lib/api-client.ts'
|
||||
- 'src/lib/host-api.ts'
|
||||
- 'src/stores/gateway.ts'
|
||||
- 'src/stores/chat.ts'
|
||||
- 'electron/gateway/**'
|
||||
- 'electron/main/ipc-handlers.ts'
|
||||
- 'electron/utils/logger.ts'
|
||||
- 'scripts/comms/**'
|
||||
- 'tests/unit/gateway-events.test.ts'
|
||||
- '.github/workflows/comms-regression.yml'
|
||||
|
||||
jobs:
|
||||
comms-regression:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: '1'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Prefer HTTPS for public GitHub git dependencies
|
||||
run: |
|
||||
git config --global "url.https://github.com/.insteadOf" "git@github.com:"
|
||||
git config --global --add "url.https://github.com/.insteadOf" "ssh://git@github.com/"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run comms replay
|
||||
run: pnpm run comms:replay
|
||||
|
||||
- name: Compare with baseline
|
||||
run: pnpm run comms:compare
|
||||
|
||||
- name: Upload comms artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: comms-regression-artifacts
|
||||
path: artifacts/comms
|
||||
@@ -0,0 +1,162 @@
|
||||
name: Electron E2E
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
electron-e2e:
|
||||
name: Electron E2E (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-latest
|
||||
- macos-latest
|
||||
- windows-latest
|
||||
env:
|
||||
CI: 'true'
|
||||
# Linux runners cannot use Electron's setuid chrome-sandbox; harmless on macOS/Windows.
|
||||
ELECTRON_DISABLE_SANDBOX: '1'
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Prefer HTTPS for public GitHub git dependencies
|
||||
run: |
|
||||
git config --global "url.https://github.com/.insteadOf" "git@github.com:"
|
||||
git config --global --add "url.https://github.com/.insteadOf" "ssh://git@github.com/"
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
echo "side-effects-cache=false" >> .npmrc
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
# electron/install.js and extract-zip both leave dist half-extracted on CI
|
||||
# (114MB zip downloads OK; only LICENSE + LICENSES.chromium.html land in dist).
|
||||
# Download with @electron/get, extract with the OS unzip tool instead.
|
||||
- name: Install Electron binary (Unix)
|
||||
if: runner.os != 'Windows'
|
||||
shell: bash
|
||||
env:
|
||||
force_no_cache: 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
unset ELECTRON_SKIP_BINARY_DOWNLOAD
|
||||
ELECTRON_DIR="$(node -p "require('path').dirname(require.resolve('electron/package.json'))")"
|
||||
echo "Electron package dir: $ELECTRON_DIR"
|
||||
rm -rf "$ELECTRON_DIR/dist" "$ELECTRON_DIR/path.txt"
|
||||
mkdir -p "$ELECTRON_DIR/dist"
|
||||
|
||||
ZIP="$(cd "$ELECTRON_DIR" && node -e "
|
||||
const { downloadArtifact } = require('@electron/get');
|
||||
const { version } = require('./package.json');
|
||||
downloadArtifact({ version, artifactName: 'electron', force: true })
|
||||
.then((z) => { process.stdout.write(z); process.exit(0); })
|
||||
.catch((e) => { console.error(e); process.exit(1); });
|
||||
")"
|
||||
ZIP_SIZE="$(stat -c%s "$ZIP" 2>/dev/null || stat -f%z "$ZIP")"
|
||||
echo "Downloaded zip: $ZIP ($ZIP_SIZE bytes)"
|
||||
|
||||
unzip -oq "$ZIP" -d "$ELECTRON_DIR/dist"
|
||||
echo "Extracted top-level entries: $(ls -1 "$ELECTRON_DIR/dist" | wc -l | tr -d ' ')"
|
||||
|
||||
if [ -f "$ELECTRON_DIR/dist/electron.d.ts" ]; then
|
||||
mv "$ELECTRON_DIR/dist/electron.d.ts" "$ELECTRON_DIR/electron.d.ts"
|
||||
fi
|
||||
|
||||
if [ "$(uname -s)" = "Darwin" ]; then
|
||||
PLATFORM_PATH='Electron.app/Contents/MacOS/Electron'
|
||||
test -f "$ELECTRON_DIR/dist/Electron.app/Contents/MacOS/Electron"
|
||||
else
|
||||
PLATFORM_PATH='electron'
|
||||
test -f "$ELECTRON_DIR/dist/electron"
|
||||
chmod +x "$ELECTRON_DIR/dist/electron"
|
||||
fi
|
||||
printf '%s' "$PLATFORM_PATH" > "$ELECTRON_DIR/path.txt"
|
||||
echo "path.txt: $(cat "$ELECTRON_DIR/path.txt")"
|
||||
|
||||
- name: Install Electron binary (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
env:
|
||||
force_no_cache: 'true'
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Remove-Item Env:ELECTRON_SKIP_BINARY_DOWNLOAD -ErrorAction SilentlyContinue
|
||||
$electronDir = node -p "require('path').dirname(require.resolve('electron/package.json'))"
|
||||
Write-Host "Electron package dir: $electronDir"
|
||||
Remove-Item -Recurse -Force "$electronDir\dist", "$electronDir\path.txt" -ErrorAction SilentlyContinue
|
||||
New-Item -ItemType Directory -Force -Path "$electronDir\dist" | Out-Null
|
||||
|
||||
Push-Location $electronDir
|
||||
try {
|
||||
$zip = node -e "const { downloadArtifact } = require('@electron/get'); const { version } = require('./package.json'); downloadArtifact({ version, artifactName: 'electron', force: true }).then((z) => { process.stdout.write(z); process.exit(0); }).catch((e) => { console.error(e); process.exit(1); });"
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
$zipSize = (Get-Item -LiteralPath $zip).Length
|
||||
Write-Host "Downloaded zip: $zip ($zipSize bytes)"
|
||||
|
||||
Expand-Archive -LiteralPath $zip -DestinationPath "$electronDir\dist" -Force
|
||||
$entryCount = (Get-ChildItem -LiteralPath "$electronDir\dist").Count
|
||||
Write-Host "Extracted top-level entries: $entryCount"
|
||||
|
||||
$typeDef = Join-Path $electronDir 'dist\electron.d.ts'
|
||||
if (Test-Path -LiteralPath $typeDef) {
|
||||
Move-Item -LiteralPath $typeDef -Destination (Join-Path $electronDir 'electron.d.ts') -Force
|
||||
}
|
||||
|
||||
$exe = Join-Path $electronDir 'dist\electron.exe'
|
||||
if (-not (Test-Path -LiteralPath $exe)) {
|
||||
throw "electron.exe missing after extract: $exe"
|
||||
}
|
||||
Set-Content -LiteralPath (Join-Path $electronDir 'path.txt') -Value 'electron.exe' -NoNewline
|
||||
Write-Host "path.txt: electron.exe"
|
||||
|
||||
- name: Verify Electron binary
|
||||
run: pnpm exec electron --version
|
||||
|
||||
- name: Generate extension bridge
|
||||
run: pnpm run ext:bridge
|
||||
|
||||
- name: Run Electron E2E on Linux
|
||||
if: runner.os == 'Linux'
|
||||
run: xvfb-run -a pnpm run test:e2e
|
||||
|
||||
- name: Run Electron E2E on macOS
|
||||
if: runner.os == 'macOS'
|
||||
run: pnpm run test:e2e
|
||||
|
||||
- name: Run Electron E2E on Windows
|
||||
if: runner.os == 'Windows'
|
||||
run: pnpm run test:e2e
|
||||
|
||||
- name: Upload Playwright artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-artifacts-${{ matrix.os }}
|
||||
path: |
|
||||
playwright-report
|
||||
test-results
|
||||
if-no-files-found: warn
|
||||
retention-days: 7
|
||||
@@ -0,0 +1,54 @@
|
||||
name: Harness
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'harness/**'
|
||||
- 'tests/unit/harness-specs.test.ts'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- '.github/workflows/harness.yml'
|
||||
|
||||
jobs:
|
||||
harness:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: '1'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Prefer HTTPS for public GitHub git dependencies
|
||||
run: |
|
||||
git config --global "url.https://github.com/.insteadOf" "git@github.com:"
|
||||
git config --global --add "url.https://github.com/.insteadOf" "ssh://git@github.com/"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run harness CI checks
|
||||
run: pnpm run harness:ci
|
||||
|
||||
- name: Upload harness artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: harness-artifacts
|
||||
path: artifacts/harness
|
||||
if-no-files-found: warn
|
||||
retention-days: 7
|
||||
@@ -43,16 +43,24 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Prefer HTTPS for public GitHub git dependencies
|
||||
run: |
|
||||
git config --global "url.https://github.com/.insteadOf" "git@github.com:"
|
||||
git config --global --add "url.https://github.com/.insteadOf" "ssh://git@github.com/"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Download uv binaries for Windows
|
||||
run: pnpm run uv:download:win
|
||||
|
||||
- name: Download agent-browser binaries for Windows
|
||||
run: pnpm run agent-browser:download:win
|
||||
|
||||
- name: Build Windows package (no publish)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: pnpm run build:vite && pnpm exec zx scripts/bundle-openclaw.mjs && pnpm exec electron-builder --win --publish never
|
||||
run: pnpm run package:win
|
||||
|
||||
- name: Upload Windows Installer (x64)
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
+339
-38
@@ -10,15 +10,28 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version to release (e.g., 1.0.0)'
|
||||
description: 'Version label for an unsigned smoke build (e.g., 1.0.0-beta.smoke)'
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
# Fails fast on tag pushes if package.json "version" does not match the tag.
|
||||
validate-release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Assert tag matches package.json
|
||||
run: node scripts/assert-tag-matches-package.mjs
|
||||
|
||||
release:
|
||||
needs: validate-release
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-latest
|
||||
@@ -32,30 +45,23 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
- name: Prefer HTTPS for public GitHub git dependencies
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
git config --global "url.https://github.com/.insteadOf" "git@github.com:"
|
||||
git config --global --add "url.https://github.com/.insteadOf" "ssh://git@github.com/"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
@@ -72,36 +78,243 @@ jobs:
|
||||
if: matrix.platform == 'linux'
|
||||
run: pnpm run uv:download:linux
|
||||
|
||||
- name: Download agent-browser binaries for macOS
|
||||
if: matrix.platform == 'mac'
|
||||
run: pnpm run agent-browser:download:mac
|
||||
|
||||
- name: Download agent-browser binaries for Windows
|
||||
if: matrix.platform == 'win'
|
||||
run: pnpm run agent-browser:download:win
|
||||
|
||||
- name: Download agent-browser binaries for Linux
|
||||
if: matrix.platform == 'linux'
|
||||
run: pnpm run agent-browser:download:linux
|
||||
|
||||
|
||||
# macOS specific steps
|
||||
- name: Free disk space (macOS)
|
||||
if: matrix.platform == 'mac'
|
||||
run: |
|
||||
echo "=== Disk usage before cleanup ==="
|
||||
df -h /
|
||||
# Remove large pre-installed toolchains not needed for Electron builds
|
||||
sudo rm -rf /usr/local/lib/android || true
|
||||
sudo rm -rf /usr/share/dotnet || true
|
||||
sudo rm -rf /usr/local/share/powershell || true
|
||||
sudo rm -rf /usr/local/share/chromium || true
|
||||
sudo rm -rf /usr/local/lib/node_modules || true
|
||||
rm -rf ~/Library/Caches/electron-builder/dmg-builder* || true
|
||||
# Homebrew cleanup
|
||||
brew cleanup --prune=all 2>/dev/null || true
|
||||
echo "=== Disk usage after cleanup ==="
|
||||
df -h /
|
||||
|
||||
# --publish never: prevent electron-builder from auto-publishing to GitHub.
|
||||
# All artifacts are collected and published atomically in the publish job.
|
||||
- name: Build macOS
|
||||
if: matrix.platform == 'mac'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CSC_LINK: ${{ secrets.MAC_CERTS }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: ${{ github.event_name == 'workflow_dispatch' && 'false' || 'true' }}
|
||||
CSC_LINK: ${{ github.event_name == 'push' && secrets.MAC_CERTS || '' }}
|
||||
CSC_KEY_PASSWORD: ${{ github.event_name == 'push' && secrets.MAC_CERTS_PASSWORD || '' }}
|
||||
APPLE_ID: ${{ github.event_name == 'push' && secrets.APPLE_ID || '' }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ github.event_name == 'push' && secrets.APPLE_APP_SPECIFIC_PASSWORD || '' }}
|
||||
APPLE_TEAM_ID: ${{ github.event_name == 'push' && secrets.APPLE_TEAM_ID || '' }}
|
||||
run: |
|
||||
ulimit -n 65536
|
||||
echo "File descriptor limit: $(ulimit -n)"
|
||||
pnpm run build:vite && pnpm exec zx scripts/bundle-openclaw.mjs && pnpm exec electron-builder --mac --publish never
|
||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||
unset CSC_LINK CSC_KEY_PASSWORD APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_TEAM_ID
|
||||
fi
|
||||
pnpm run package:mac
|
||||
|
||||
- name: Verify macOS packaged runtime resources
|
||||
if: matrix.platform == 'mac'
|
||||
run: |
|
||||
pnpm run verify:packaged-runtime-resources -- --resources=release/mac/ClawX.app/Contents/Resources --platform=darwin --arch=x64
|
||||
pnpm run verify:packaged-runtime-resources -- --resources=release/mac-arm64/ClawX.app/Contents/Resources --platform=darwin --arch=arm64
|
||||
|
||||
- name: Smoke native macOS packaged cc-connect runtime
|
||||
if: matrix.platform == 'mac'
|
||||
run: pnpm run smoke:cc-connect:packaged -- --allow-unsigned=${{ github.event_name == 'workflow_dispatch' && '1' || '0' }}
|
||||
|
||||
# Windows specific steps
|
||||
- name: Build Windows
|
||||
if: matrix.platform == 'win'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: pnpm run build:vite && pnpm exec zx scripts/bundle-openclaw.mjs && pnpm exec electron-builder --win --publish never
|
||||
run: pnpm run package:win
|
||||
|
||||
- name: Verify Windows packaged runtime resources
|
||||
if: matrix.platform == 'win'
|
||||
run: pnpm run verify:packaged-runtime-resources -- --resources=release/win-unpacked/resources --platform=win32 --arch=x64
|
||||
|
||||
- name: Smoke native Windows packaged cc-connect runtime
|
||||
if: matrix.platform == 'win'
|
||||
run: pnpm run smoke:cc-connect:packaged
|
||||
|
||||
# Detect release channel from tag to skip code signing for alpha/beta builds
|
||||
- name: Detect Windows release channel
|
||||
if: matrix.platform == 'win'
|
||||
id: win-channel
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
|
||||
TAG="${GITHUB_REF#refs/tags/v}"
|
||||
else
|
||||
TAG="${{ github.event.inputs.version }}"
|
||||
fi
|
||||
if [[ "$TAG" =~ (alpha|beta) ]]; then
|
||||
echo "is_stable=false" >> $GITHUB_OUTPUT
|
||||
echo "Channel: prerelease ($TAG) — skipping code signing"
|
||||
else
|
||||
echo "is_stable=true" >> $GITHUB_OUTPUT
|
||||
echo "Channel: stable ($TAG) — will sign"
|
||||
fi
|
||||
|
||||
- name: Validate unsigned Windows artifacts before SignPath
|
||||
if: matrix.platform == 'win' && steps.win-channel.outputs.is_stable == 'true'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$unsignedExeFiles = Get-ChildItem -Path "release" -Filter *.exe -File
|
||||
if (-not $unsignedExeFiles) {
|
||||
throw "No unsigned .exe files found in release/ before SignPath upload"
|
||||
}
|
||||
$unsignedCount = $unsignedExeFiles.Count
|
||||
"UNSIGNED_EXE_COUNT=$unsignedCount" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
Write-Host "Found $unsignedCount unsigned .exe file(s):"
|
||||
$unsignedExeFiles | ForEach-Object { Write-Host " - $($_.Name)" }
|
||||
|
||||
- name: Upload unsigned Windows artifacts for SignPath
|
||||
if: matrix.platform == 'win' && steps.win-channel.outputs.is_stable == 'true'
|
||||
id: upload-unsigned-windows-artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: unsigned-win-exe-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: release/*.exe
|
||||
retention-days: 1
|
||||
|
||||
- name: Sign Windows artifacts via SignPath
|
||||
if: matrix.platform == 'win' && steps.win-channel.outputs.is_stable == 'true'
|
||||
id: signpath-sign-windows
|
||||
uses: signpath/github-action-submit-signing-request@v2
|
||||
with:
|
||||
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
organization-id: "78e37079-23df-4800-b41c-33312ad7c1e3"
|
||||
project-slug: "ValueCell"
|
||||
signing-policy-slug: "ValueCell-sign"
|
||||
github-artifact-id: ${{ steps.upload-unsigned-windows-artifact.outputs.artifact-id }}
|
||||
wait-for-completion: true
|
||||
output-artifact-directory: release/signed
|
||||
|
||||
- name: Replace unsigned executables with signed ones
|
||||
if: matrix.platform == 'win' && steps.win-channel.outputs.is_stable == 'true'
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "SignPath GitHub artifact ID: ${{ steps.upload-unsigned-windows-artifact.outputs.artifact-id }}"
|
||||
$signedExeFiles = Get-ChildItem -Path "release/signed" -Filter *.exe -File -Recurse
|
||||
if (-not $signedExeFiles) {
|
||||
throw "No signed .exe files found in release/signed"
|
||||
}
|
||||
$signedCount = $signedExeFiles.Count
|
||||
if ($env:UNSIGNED_EXE_COUNT -and ($signedCount -ne [int]$env:UNSIGNED_EXE_COUNT)) {
|
||||
throw "Signed .exe count ($signedCount) does not match unsigned count ($env:UNSIGNED_EXE_COUNT)"
|
||||
}
|
||||
foreach ($file in $signedExeFiles) {
|
||||
Copy-Item -Path $file.FullName -Destination "release/$($file.Name)" -Force
|
||||
}
|
||||
$finalExeFiles = Get-ChildItem -Path "release" -Filter *.exe -File
|
||||
if ($env:UNSIGNED_EXE_COUNT -and ($finalExeFiles.Count -ne [int]$env:UNSIGNED_EXE_COUNT)) {
|
||||
throw "Final release .exe count ($($finalExeFiles.Count)) does not match unsigned count ($env:UNSIGNED_EXE_COUNT)"
|
||||
}
|
||||
Write-Host "Signed executables copied to release/ ($($finalExeFiles.Count) file(s))"
|
||||
|
||||
# Code signing changes the .exe binary, invalidating the sha512 hash that
|
||||
# electron-builder wrote into latest.yml during the initial build.
|
||||
# Recalculate the hash for each signed .exe and patch the yml files so
|
||||
# electron-updater can verify the download successfully.
|
||||
#
|
||||
# Actual latest.yml structure (from electron-builder NSIS):
|
||||
# files:
|
||||
# - url: ClawX-0.2.4-win-x64.exe ← files[] entries have url/sha512/size
|
||||
# sha512: <base64>
|
||||
# size: 430775882
|
||||
# path: ClawX-0.2.4-win-arm64.exe ← top-level has path/sha512 (no size!)
|
||||
# sha512: <base64>
|
||||
# releaseDate: '...'
|
||||
- name: Update latest.yml sha512 after code signing
|
||||
if: matrix.platform == 'win' && steps.win-channel.outputs.is_stable == 'true'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ymlFiles = Get-ChildItem -Path "release" -Filter "*.yml" -File | Where-Object { $_.Name -ne "builder-debug.yml" }
|
||||
$exeFiles = Get-ChildItem -Path "release" -Filter "*.exe" -File
|
||||
|
||||
foreach ($yml in $ymlFiles) {
|
||||
$content = Get-Content $yml.FullName -Raw
|
||||
$modified = $false
|
||||
|
||||
foreach ($exe in $exeFiles) {
|
||||
# Compute new sha512 (base64) for the signed exe
|
||||
$hash = Get-FileHash -Path $exe.FullName -Algorithm SHA512
|
||||
$hashBytes = [byte[]]::new($hash.Hash.Length / 2)
|
||||
for ($i = 0; $i -lt $hashBytes.Length; $i++) {
|
||||
$hashBytes[$i] = [Convert]::ToByte($hash.Hash.Substring($i * 2, 2), 16)
|
||||
}
|
||||
$newSha512 = [Convert]::ToBase64String($hashBytes)
|
||||
$newSize = (Get-Item $exe.FullName).Length
|
||||
$escapedName = [Regex]::Escape($exe.Name)
|
||||
|
||||
# 1) files[] entries: url: <name>\n sha512: <hash>\n size: <n>
|
||||
$urlPattern = "(?m)(url:\s*${escapedName}\s*\r?\n\s*sha512:\s*)(\S+)(\s*\r?\n\s*size:\s*)(\d+)"
|
||||
if ($content -match $urlPattern) {
|
||||
$content = $content -replace $urlPattern, "`${1}${newSha512}`${3}${newSize}"
|
||||
$modified = $true
|
||||
Write-Host "Updated $($yml.Name) files[]: $($exe.Name) sha512=$newSha512 size=$newSize"
|
||||
}
|
||||
|
||||
# 2) Top-level entry: path: <name>\nsha512: <hash>\n (no size field)
|
||||
$pathPattern = "(?m)(path:\s*${escapedName}\s*\r?\n)sha512:\s*\S+"
|
||||
if ($content -match $pathPattern) {
|
||||
$content = $content -replace $pathPattern, "`${1}sha512: ${newSha512}"
|
||||
$modified = $true
|
||||
Write-Host "Updated $($yml.Name) top-level: $($exe.Name) sha512=$newSha512"
|
||||
}
|
||||
}
|
||||
|
||||
if ($modified) {
|
||||
Set-Content -Path $yml.FullName -Value $content -NoNewline
|
||||
Write-Host "Saved updated $($yml.Name)"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "=== Final yml contents ==="
|
||||
foreach ($yml in $ymlFiles) {
|
||||
Write-Host "--- $($yml.Name) ---"
|
||||
Get-Content $yml.FullName
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
# Linux specific steps
|
||||
- name: Build Linux
|
||||
if: matrix.platform == 'linux'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: pnpm run build:vite && pnpm exec zx scripts/bundle-openclaw.mjs && pnpm exec electron-builder --linux --publish never
|
||||
run: pnpm run package:linux
|
||||
|
||||
- name: Verify Linux packaged runtime resources
|
||||
if: matrix.platform == 'linux'
|
||||
run: |
|
||||
pnpm run verify:packaged-runtime-resources -- --resources=release/linux-unpacked/resources --platform=linux --arch=x64
|
||||
pnpm run verify:packaged-runtime-resources -- --resources=release/linux-arm64-unpacked/resources --platform=linux --arch=arm64
|
||||
|
||||
- name: Smoke native Linux x64 packaged cc-connect runtime
|
||||
if: matrix.platform == 'linux'
|
||||
run: xvfb-run -a pnpm run smoke:cc-connect:packaged
|
||||
|
||||
- name: Upload native runtime smoke evidence
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: runtime-smoke-${{ matrix.platform }}-native
|
||||
path: artifacts/cc-connect/packaged-smoke-*.json
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -119,21 +332,110 @@ jobs:
|
||||
!release/builder-debug.yml
|
||||
retention-days: 7
|
||||
|
||||
runtime-smoke-macos-x64:
|
||||
needs: validate-release
|
||||
runs-on: macos-15-intel
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Prefer HTTPS for public GitHub git dependencies
|
||||
run: |
|
||||
git config --global "url.https://github.com/.insteadOf" "git@github.com:"
|
||||
git config --global --add "url.https://github.com/.insteadOf" "ssh://git@github.com/"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Build native macOS x64 unpacked app
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
|
||||
SKIP_PREINSTALLED_SKILLS: '1'
|
||||
run: |
|
||||
pnpm run package
|
||||
node scripts/run-electron-builder.mjs --mac dir --x64 --publish never
|
||||
|
||||
- name: Verify and smoke native macOS x64 runtime
|
||||
run: |
|
||||
pnpm run verify:packaged-runtime-resources -- --resources=release/mac/ClawX.app/Contents/Resources --platform=darwin --arch=x64
|
||||
pnpm run smoke:cc-connect:packaged -- --allow-unsigned=1
|
||||
|
||||
- name: Upload macOS x64 runtime smoke evidence
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: runtime-smoke-macos-x64
|
||||
path: artifacts/cc-connect/packaged-smoke-darwin-x64.json
|
||||
retention-days: 7
|
||||
|
||||
runtime-smoke-linux-arm64:
|
||||
needs: validate-release
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Prefer HTTPS for public GitHub git dependencies
|
||||
run: |
|
||||
git config --global "url.https://github.com/.insteadOf" "git@github.com:"
|
||||
git config --global --add "url.https://github.com/.insteadOf" "ssh://git@github.com/"
|
||||
|
||||
- name: Install dependencies and X virtual framebuffer
|
||||
run: |
|
||||
pnpm install
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y xvfb
|
||||
|
||||
- name: Build native Linux arm64 unpacked app
|
||||
env:
|
||||
SKIP_PREINSTALLED_SKILLS: '1'
|
||||
run: |
|
||||
pnpm run package
|
||||
node scripts/run-electron-builder.mjs --linux dir --arm64 --publish never
|
||||
|
||||
- name: Verify and smoke native Linux arm64 runtime
|
||||
run: |
|
||||
pnpm run verify:packaged-runtime-resources -- --resources=release/linux-arm64-unpacked/resources --platform=linux --arch=arm64
|
||||
xvfb-run -a pnpm run smoke:cc-connect:packaged
|
||||
|
||||
- name: Upload Linux arm64 runtime smoke evidence
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: runtime-smoke-linux-arm64
|
||||
path: artifacts/cc-connect/packaged-smoke-linux-arm64.json
|
||||
retention-days: 7
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Job: Publish to GitHub Releases
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
publish:
|
||||
needs: release
|
||||
needs: [release, runtime-smoke-macos-x64, runtime-smoke-linux-arm64]
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download all artifacts
|
||||
- name: Download release artifacts only
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: release-artifacts
|
||||
pattern: release-*
|
||||
|
||||
- name: List all downloaded artifacts
|
||||
run: |
|
||||
@@ -218,17 +520,16 @@ jobs:
|
||||
# releases/vX.Y.Z/ → permanent archive, never deleted
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
upload-oss:
|
||||
needs: release
|
||||
needs: [release, runtime-smoke-macos-x64, runtime-smoke-linux-arm64]
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download all artifacts
|
||||
- name: Download release artifacts only
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: release-artifacts
|
||||
pattern: release-*
|
||||
|
||||
- name: Extract version and channel
|
||||
id: version
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
name: Windows Build Test
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version for SignPath parameter (e.g., 1.0.0-test.1)"
|
||||
required: false
|
||||
default: "dev"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
windows-build-sign:
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Prefer HTTPS for public GitHub git dependencies
|
||||
run: |
|
||||
git config --global "url.https://github.com/.insteadOf" "git@github.com:"
|
||||
git config --global --add "url.https://github.com/.insteadOf" "ssh://git@github.com/"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Download uv binaries for Windows
|
||||
run: pnpm run uv:download:win
|
||||
|
||||
- name: Download agent-browser binaries for Windows
|
||||
run: pnpm run agent-browser:download:win
|
||||
|
||||
- name: Build Windows
|
||||
run: pnpm run package:win
|
||||
|
||||
- name: Validate unsigned Windows artifacts before SignPath
|
||||
shell: pwsh
|
||||
run: |
|
||||
$unsignedExeFiles = Get-ChildItem -Path "release" -Filter *.exe -File
|
||||
if (-not $unsignedExeFiles) {
|
||||
throw "No unsigned .exe files found in release/ before SignPath upload"
|
||||
}
|
||||
$unsignedCount = $unsignedExeFiles.Count
|
||||
"UNSIGNED_EXE_COUNT=$unsignedCount" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
Write-Host "Found $unsignedCount unsigned .exe file(s):"
|
||||
$unsignedExeFiles | ForEach-Object { Write-Host " - $($_.Name)" }
|
||||
|
||||
# Required by SignPath Trusted Build: artifact must exist on GitHub first.
|
||||
- name: Upload unsigned Windows artifacts for SignPath
|
||||
id: upload-unsigned-windows-artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: unsigned-win-exe-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: release/*.exe
|
||||
retention-days: 1
|
||||
|
||||
- name: Sign Windows artifacts via SignPath
|
||||
id: signpath-sign-windows
|
||||
uses: signpath/github-action-submit-signing-request@v2
|
||||
with:
|
||||
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
organization-id: "78e37079-23df-4800-b41c-33312ad7c1e3"
|
||||
project-slug: "ValueCell"
|
||||
signing-policy-slug: "ValueCell-sign"
|
||||
github-artifact-id: ${{ steps.upload-unsigned-windows-artifact.outputs.artifact-id }}
|
||||
wait-for-completion: true
|
||||
output-artifact-directory: release/signed
|
||||
|
||||
- name: Replace unsigned executables with signed ones
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "SignPath GitHub artifact ID: ${{ steps.upload-unsigned-windows-artifact.outputs.artifact-id }}"
|
||||
$signedExeFiles = Get-ChildItem -Path "release/signed" -Filter *.exe -File -Recurse
|
||||
if (-not $signedExeFiles) {
|
||||
throw "No signed .exe files found in release/signed"
|
||||
}
|
||||
$signedCount = $signedExeFiles.Count
|
||||
if ($env:UNSIGNED_EXE_COUNT -and ($signedCount -ne [int]$env:UNSIGNED_EXE_COUNT)) {
|
||||
throw "Signed .exe count ($signedCount) does not match unsigned count ($env:UNSIGNED_EXE_COUNT)"
|
||||
}
|
||||
foreach ($file in $signedExeFiles) {
|
||||
Copy-Item -Path $file.FullName -Destination "release/$($file.Name)" -Force
|
||||
}
|
||||
$finalExeFiles = Get-ChildItem -Path "release" -Filter *.exe -File
|
||||
if ($env:UNSIGNED_EXE_COUNT -and ($finalExeFiles.Count -ne [int]$env:UNSIGNED_EXE_COUNT)) {
|
||||
throw "Final release .exe count ($($finalExeFiles.Count)) does not match unsigned count ($env:UNSIGNED_EXE_COUNT)"
|
||||
}
|
||||
Write-Host "Signed executables copied to release/ ($($finalExeFiles.Count) file(s))"
|
||||
|
||||
- name: Upload signed Windows artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: signed-win-exe-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: |
|
||||
release/*.exe
|
||||
release/latest*.yml
|
||||
retention-days: 7
|
||||
+26
-1
@@ -35,9 +35,15 @@ yarn-error.log*
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
|
||||
# Test coverage
|
||||
coverage/
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
# Local session transcript fixtures (may contain private conversation data)
|
||||
tests/fixtures/transcripts/
|
||||
|
||||
# Cache
|
||||
.cache/
|
||||
@@ -59,6 +65,25 @@ resources/bin
|
||||
*.key
|
||||
|
||||
build/
|
||||
artifacts/
|
||||
.delivery/
|
||||
docs/pr-session-notes-*.md
|
||||
|
||||
.cursor/
|
||||
.pnpm-store/
|
||||
.claude/
|
||||
.pnpm-store/
|
||||
package-lock.json
|
||||
|
||||
# Generated extension bridges (created by scripts/generate-ext-bridge.mjs)
|
||||
electron/extensions/_ext-bridge.generated.ts
|
||||
src/extensions/_ext-bridge.generated.ts
|
||||
|
||||
# Local playground artifacts
|
||||
playground/
|
||||
|
||||
# ClawX-biz bridge workspace artifacts
|
||||
resources/enterprise-skills/
|
||||
resources/openclaw-plugins/skillshub/
|
||||
|
||||
.opencode
|
||||
.superpowers
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
package-import-method=copy
|
||||
shamefully-hoist=true
|
||||
strict-peer-dependencies=false
|
||||
|
||||
electron_mirror=https://npmmirror.com/mirrors/electron/
|
||||
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||
|
||||
+3
-3
@@ -2,6 +2,6 @@
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100
|
||||
}
|
||||
"trailingComma": "all",
|
||||
"printWidth": 120
|
||||
}
|
||||
@@ -17,6 +17,9 @@ Standard dev commands are in `package.json` scripts and `README.md`. Key ones:
|
||||
| Lint (ESLint, auto-fix) | `pnpm run lint` |
|
||||
| Type check | `pnpm run typecheck` |
|
||||
| Unit tests (Vitest) | `pnpm test` |
|
||||
| Comms replay metrics | `pnpm run comms:replay` |
|
||||
| Comms baseline refresh | `pnpm run comms:baseline` |
|
||||
| Comms regression compare | `pnpm run comms:compare` |
|
||||
| E2E tests (Playwright) | `pnpm run test:e2e` |
|
||||
| Build frontend only | `pnpm run build:vite` |
|
||||
|
||||
@@ -30,12 +33,19 @@ Standard dev commands are in `package.json` scripts and `README.md`. Key ones:
|
||||
- **Gateway startup**: When running `pnpm dev`, the OpenClaw Gateway process starts automatically on port 18789. It takes ~10-30 seconds to become ready. Gateway readiness is not required for UI development—the app functions without it (shows "connecting" state).
|
||||
- **No database**: The app uses `electron-store` (JSON files) and OS keychain. No database setup is needed.
|
||||
- **AI Provider keys**: Actual AI chat requires at least one provider API key configured via Settings > AI Providers. The app is fully navigable and testable without keys.
|
||||
- **Token usage history implementation**: Dashboard token usage history is not parsed from console logs. It reads OpenClaw session transcript `.jsonl` files under the local OpenClaw config directory, scans both configured agents and any runtime agent directories found on disk, and treats normal, `.deleted.jsonl`, and `.jsonl.reset.*` transcripts as valid history sources. It extracts assistant/tool usage records with `message.usage` and aggregates fields such as input/output/cache/total tokens and cost from those structured records.
|
||||
- **Token usage history implementation**: Dashboard token usage history is not parsed from console logs. It reads OpenClaw session transcript `.jsonl` files under the local OpenClaw config directory, scans both configured agents and any runtime agent directories found on disk, and treats normal, `.deleted.jsonl`, and `.jsonl.reset.*` transcripts as valid history sources. It extracts assistant/tool usage records with `message.usage` and aggregates fields such as input/output/cache/total tokens and cost from those structured records. Note: "Delete conversation" in the sidebar is a hard delete — the Main process unlinks `<id>.jsonl` plus any leftover `<id>.deleted.jsonl` and `<id>.jsonl.reset.*` siblings, *and* OpenClaw's trajectory artefacts (`<id>.trajectory.jsonl` flight recorder + `<id>.trajectory-path.json` pointer); when the pointer references a runtime file outside the agent's `sessions/` folder (the `OPENCLAW_TRAJECTORY_DIR` override), that off-disk file is unlinked too. Deleted conversations stop contributing to this chart — use a fresh session if you want history retained.
|
||||
- **Models page aggregation**: The 7-day/30-day filters are relative rolling windows, not calendar-month buckets. When grouped by time, the chart should keep all day buckets in the selected window; only model grouping is intentionally capped to the top entries.
|
||||
- **OpenClaw Doctor in UI**: In Settings > Advanced > Developer, the app exposes both `Run Doctor` (`openclaw doctor --json`) and `Run Doctor Fix` (`openclaw doctor --fix --yes --non-interactive`) through the host-api. Renderer code should call the host route, not spawn CLI processes directly.
|
||||
- **UI change validation**: Any user-visible UI change should include or update an Electron E2E spec in the same PR so the interaction is covered by Playwright.
|
||||
- **i18n & styling conventions**: New user-facing features must (1) route all text through `react-i18next` with full locale coverage (`en` / `zh` / `ja` / `ru` under `shared/i18n/locales/<lang>/<ns>.json`) — never hardcode display strings, and (2) use the design tokens and substitution rules documented in `src/styles/globals.css` (surfaces `bg-surface-modal` / `bg-surface-input`, selected state `bg-black/5 dark:bg-white/10`, status colours `text-X-700 dark:text-X-400`, page H1/H2 `font-serif font-normal tracking-tight`, etc.) — see the *Component conventions* block in `globals.css` for the full substitution table.
|
||||
- **Renderer/Main API boundary (important)**:
|
||||
- Renderer must use `src/lib/host-api.ts` and `src/lib/api-client.ts` as the single entry for backend calls.
|
||||
- Do not add new direct `window.electron.ipcRenderer.invoke(...)` calls in pages/components; expose them through host-api/api-client instead.
|
||||
- Do not call Gateway HTTP endpoints directly from renderer (`fetch('http://127.0.0.1:18789/...')` etc.). Use Main-process proxy channels (`hostapi:fetch`, `gateway:httpProxy`) to avoid CORS/env drift.
|
||||
- Transport policy is Main-owned and fixed as `WS -> HTTP -> IPC fallback`; renderer should not implement protocol switching UI/business logic.
|
||||
- **Comms-change checklist**: If your change touches communication paths (gateway events, runtime send/receive, delivery, or fallback), run `pnpm run comms:replay` and `pnpm run comms:compare` before pushing.
|
||||
- **Doc sync rule**: After any functional or architecture change, review `README.md`, `README.zh-CN.md`, and `README.ja-JP.md` for required updates; if behavior/flows/interfaces changed, update docs in the same PR/commit.
|
||||
- **Spec-driven harness rule**: AI Coding tasks that touch backend communication must start from a task spec under `harness/specs/tasks/` and reference `gateway-backend-communication` when the change involves renderer/Main/host-api/api-client/Gateway/OpenClaw runtime paths. Run `pnpm harness validate --spec <task-spec>` before implementation review, and `pnpm harness run --spec <task-spec>` or `--dry-run` when checking the selected validation flow.
|
||||
- **Spec/rule growth rule**: When adding a new feature, user-visible OpenClaw scenario, or recurring AI Coding constraint, add or update the relevant harness scenario spec and rule spec in the same PR so future AI work can validate the behavior instead of relying on tribal knowledge.
|
||||
- **Harness CI/local parity**: Run `pnpm run harness:ci` to exercise the same baseline harness checks used by GitHub Actions. Real task specs should be validated without `--no-diff`; `--no-diff` is only for structural checks of checked-in examples.
|
||||
- **Harness reference docs**: Keep durable, non-executable architecture and compatibility details under `harness/reference/`. Link them from the relevant scenario, rule, and task specs, but do not pass reference documents to `harness validate` or `harness run`.
|
||||
|
||||
+192
-57
@@ -30,7 +30,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | 日本語
|
||||
<a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | 日本語 | <a href="README.ru-RU.md">Русский</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -43,6 +43,8 @@
|
||||
|
||||
ClawXはベストプラクティスのモデルプロバイダーが事前設定されており、Windowsおよび多言語設定をネイティブにサポートしています。もちろん、**設定 → 詳細設定 → 開発者モード**から高度な設定を微調整することもできます。
|
||||
|
||||
<p align="center"><strong style="font-size:1.1em; text-decoration: underline;">完全なエンタープライズ版、専用のサービスサポート、または御社のビジネスシナリオに合わせた導入支援が必要な場合は、<a href="mailto:public@valuecell.ai">public@valuecell.ai</a> までお問い合わせください。</strong></p>
|
||||
|
||||
---
|
||||
## スクリーンショット
|
||||
|
||||
@@ -81,6 +83,7 @@ AIエージェントの構築にコマンドラインの習得は不要である
|
||||
| 複雑なCLIセットアップ | ワンクリックインストールとガイド付きセットアップウィザード |
|
||||
| 設定ファイル | リアルタイムバリデーション付きのビジュアル設定 |
|
||||
| プロセス管理 | ゲートウェイライフサイクルの自動管理 |
|
||||
| アプリ更新 | 起動時に更新を確認し、ダウンロードやインストール前に通知 |
|
||||
| 複数のAIプロバイダー | 統合プロバイダー設定パネル |
|
||||
| スキル/プラグインのインストール | 組み込みのスキルマーケットプレイスと管理機能 |
|
||||
|
||||
@@ -90,6 +93,18 @@ ClawXは公式の**OpenClaw**コアを直接ベースに構築されています
|
||||
|
||||
私たちはアップストリームのOpenClawプロジェクトとの厳密な整合性を維持することにコミットしており、公式リリースが提供する最新の機能、安定性の改善、エコシステムの互換性に常にアクセスできることを保証します。
|
||||
|
||||
開発者モードを有効にし、OpenClaw が active runtime の場合、サイドバーにはネイティブの Dreams ページも表示され、ClawX 内で OpenClaw の記憶レビュー、夢日記、基本メンテナンス操作を扱えます。詳細な診断が必要な場合は、そのページから完全版の OpenClaw Dreams UI も開けます。
|
||||
|
||||
ClawX には runtime 抽象レイヤーもあります。OpenClaw は既定 runtime とロールバック経路のままで、**設定 → Gateway → Runtime** から任意の同梱 `cc-connect` runtime に切り替えられます。パッケージ版は cc-connect バイナリと OpenAI Codex ネイティブ CLI bundle の両方を app resources に含め、runtime 起動はグローバルインストール、PATH 上のバイナリ、起動時ダウンロードに依存しません。ClawX はアップグレード後も共有できる app config、credential、runtime data、skills、workspace を `~/.clawx`(または `CLAWX_DATA_HOME`)に保持し、`~/.cc-connect` を自動変更しません。GUI chat は cc-connect BridgePlatform 経由で Codex project agent に接続し、管理 project は cc-connect の Codex app-server stdio backend を使うため、リアルタイムの tool progress を共通 Chat execution graph へ直接反映できます。cc-connect の公開 history に channel session の tool packet がない場合、ClawX は所有する Agent の workspace に限定して一致するローカル Codex transcript から history を補完します。承認ボタンと cc-connect card の選択肢は実行グラフに表示され、応答はすべて cc-connect の公開 `card_action` プロトコルを通じて返されます。Runtime が生成した画像、ファイル、音声、動画の packet も BridgePlatform 経由で返り、Chat の添付として表示され続けます。各 Agent は既定でフルオートを使用し、Agent のモデル/runtime 設定で「承認を求める」(`suggest`)を個別に選択できます。新しい agent は `~/.clawx/workspaces/agents/<id>` を使い、既存の OpenClaw workspace は移動や所有権変更なしで元のパスを再利用できます。provider/model、native cron、enabled skills は管理された cc-connect/Codex runtime に同期されます。
|
||||
|
||||
Agent と channel の設定は `~/.clawx` を canonical source とします。cc-connect が active の間は保存しても `~/.openclaw/openclaw.json` を書き換えず、OpenClaw に戻すと Gateway 起動前に互換 projection を再生成します。
|
||||
|
||||
cc-connect mode では、Codex provider sync は OpenAI API key、OpenAI OAuth/Codex、Ollama、および Responses API を公開する OpenAI-compatible Custom provider をサポートします。Custom provider の header は環境変数参照として管理 config に書き込まれるため、secret や session header は永続化されません。Chat Completions として設定された Custom provider は、この経路が Codex の Responses wire API を使うため、chat 配信前に unsupported として報告されます。
|
||||
|
||||
OAuth provider account ごとに独立した管理 `CODEX_HOME` を持ちます。runtime 起動時にユーザーのグローバル Codex login を自動採用することはなく、選択した account に対する明示的な Codex OAuth import が必要です。
|
||||
|
||||
cc-connect はメッセージング platform bridge も担当します。cc-connect が active runtime の場合、channel status probe は OpenClaw Gateway に固定せず runtime abstraction 経由でルーティングされ、設定済み channel account はバインド先 agent を所有する cc-connect project にミラーされます。channel の保存や削除では cc-connect Management API で管理 config を reload し、可能な場合は完全な runtime restart なしで platform 変更を反映します。Developer Mode のサイドバーのページショートカットは cc-connect Web Admin を開き、OpenClaw Dreams ショートカットは OpenClaw runtime 専用のままです。
|
||||
|
||||
---
|
||||
|
||||
## 機能
|
||||
@@ -98,27 +113,49 @@ ClawXは公式の**OpenClaw**コアを直接ベースに構築されています
|
||||
インストールから最初のAIインタラクションまで、すべてのセットアップを直感的なグラフィカルインターフェースで完了できます。ターミナルコマンド不要、YAMLファイル不要、環境変数の探索も不要です。
|
||||
|
||||
### 💬 インテリジェントチャットインターフェース
|
||||
モダンなチャット体験を通じてAIエージェントとコミュニケーションできます。複数の会話コンテキスト、メッセージ履歴、Markdownによるリッチコンテンツレンダリングに加え、マルチエージェント構成ではメイン入力欄の `@agent` から対象エージェントへ直接ルーティングできます。
|
||||
モダンなチャット体験を通じてAIエージェントとコミュニケーションできます。複数の会話コンテキスト、メッセージ履歴、Markdownによるリッチコンテンツレンダリング(GitHub 風テーブルや KaTeX による LaTeX 数式 `$インライン$`、`$$ブロック$$`、`\(インライン\)`、`\[ブロック\]` を含む)に加え、マルチエージェント構成ではメイン入力欄の `@agent` から対象エージェントへ直接ルーティングできます。
|
||||
コンポーザーから挿入した Skill は `/skill-name` 形式のチップとして表示され、チップをクリックすると右側のプレビュー側欄でその Skill の `SKILL.md` を開けます。
|
||||
`@agent` で別のエージェントを選ぶと、ClawX はデフォルトエージェントを経由せず、そのエージェント自身の会話コンテキストへ直接切り替えます。各エージェントのワークスペースは既定で分離されていますが、より強い実行時分離は OpenClaw の sandbox 設定に依存します。
|
||||
セッション側欄はワークスペース優先で整理され、既定ワークスペースを先頭に固定し、その他のワークスペースは自然順に並べます。各ワークスペースは折りたたみや追加読み込みができます。AI の返信中は行にスピナーが表示され、未確認の返信が完了すると青い点に変わり、会話を開くと相対アクティビティ時刻に戻ります。ホバーすると引き続き操作ボタンが表示されます。インポートしたワークスペースは側欄の見出しから名前を変更でき、新しい名前はチャット入力欄の下にも反映されます。見出しにホバーすると引き続きファイルシステムのパスを確認できます。選択中の会話に有効なワークスペースがある場合、新しいチャットはそれを引き継ぎ、最初の送信までは変更できます。編集可能な新規または未バインドのチャットでは、コンポーザーのワークスペースチップから最近使用したワークスペースと既存セッションのワークスペースの一覧を開き、既定ワークスペースへ戻すか別フォルダーを選べます。保存済みのワークスペースフォルダーが移動または削除されている場合、Chat はセッション作成を一時停止し、無効なパスを繰り返し再試行せずに既存のフォルダーを選ぶよう案内します。利用できない既定以外のグループには側欄で印が付き、確認後に削除できます。この操作ではグループ内の全セッションが完全に削除されます。OpenClaw が生成する UUID と日付のフォールバックタイトルは、そのセッション ID と一致する場合に限って欠落タイトルとして扱い、セッション名として保存せず、会話の最初のユーザーメッセージに置き換えて表示します。
|
||||
各 Agent は `provider/model` の実行時設定を個別に上書きできます。上書きしていない Agent は引き続きグローバルの既定モデルを継承します。
|
||||
|
||||
Chat の右パネルにあるワークスペースとプレビューの各タブでは、`.docx` と `.pptx` ファイルを読み取り専用でプレビューできます。従来形式の `.doc` と `.ppt` はアプリ内ではプレビューせず、引き続き OS 経由で開きます。DOCX のページ区切りは Microsoft Word と異なる場合があり、PPTX プレビューではアニメーション、画面切り替え、メディア再生をサポートしません。20 MB を超える Office ファイルはアプリ内でプレビューされません。
|
||||
|
||||
### シングルページ Web ブラウザ
|
||||
Chat の右パネルには、ワークスペース、プレビュー、変更、ウェブブラウザの 4 タブがあります。ウェブブラウザは初回利用時に 1 つのライブページを遅延作成し、パネルを閉じる、別のパネルタブを選ぶ、チャットセッションを切り替える、または ClawX の別ルートへ移動しても、ページを非表示にするだけで実行を継続します。そのため、非表示中もスクリプト、ネットワーク通信、音声、リソース消費が続く場合があります。専用の永続セッションはアプリ再起動後も Cookie とサイトストレージを保持しますが、起動ごとに `about:blank` から始まり、以前の URL、ページ状態、ナビゲーション履歴は復元しません。ページが favicon を提供する場合はタイトルの左側に表示され、favicon がない間は同じサイズのプレースホルダーでタイトル位置を維持します。アドレス編集中はアイコン領域全体が非表示になります。追加のブラウザタブやウィンドウ、ブックマーク、履歴の永続化、パスワードマネージャー、自動入力管理はありません。
|
||||
|
||||
トップレベルナビゲーションでは HTTP、HTTPS、および明示的に入力した標準 `file:///` URL を利用できます。通常のファイルシステムパスとその他のプロトコルは拒否されます。ローカルファイルを開くと、通常の Chromium セキュリティ規則の範囲で、読み取り可能な内容が埋め込みページに公開されます。また、`file:` URL に **システムブラウザで開く**を使うと、ブラウザではなく OS の関連付け済みアプリが起動する場合があります。許可されたポップアップ先は子ウィンドウを作らず現在のページを置き換えます。この同一ページへのフォールバックでは、`window.opener`、返されたウィンドウハンドル、空白ページを後から書き換えるスクリプト型ポップアップ、POST 本文や referrer、名前付きウィンドウ、ウィンドウ機能の完全な動作を維持できません。
|
||||
|
||||
ダウンロードには Electron と OS の既定動作がそのまま使われます。プラットフォームによってはネイティブの保存ダイアログが表示され、ユーザー操作が必要です。ClawX はカスタム保存先を指定せず、ダウンロードの進捗、履歴、管理 UI も提供しません。カメラとマイクはリクエストごとにネイティブの許可/拒否ダイアログを表示し、選択を記憶しません。クリップボードアクセスは許可され、位置情報、画面キャプチャ、通知、その他の権限は拒否されます。
|
||||
|
||||
**Cookie を消去**はブラウザセッション内の全オリジンの Cookie のみを削除し、キャッシュとサイトストレージを保持します。**サイトデータを消去**は全オリジンの HTTP/Chromium キャッシュ、Cache Storage、Local Storage、IndexedDB、Service Worker を削除し、Cookie とダウンロード済みファイルを保持します。ブラウザ通信は Electron/Chromium のシステムプロキシ解決に従います。ClawX クライアントのプロキシ設定はこのブラウザセッションへ同期されず、設定を変更しても再構成されません。
|
||||
|
||||
### 📡 マルチチャネル管理
|
||||
複数のAIチャネルを同時に設定・監視できます。各チャネルは独立して動作するため、異なるタスクに特化したエージェントを実行できます。
|
||||
現在は各チャンネルで複数アカウントを扱え、Channels ページでアカウントの Agent 紐付けやデフォルトアカウント切替を直接管理できます。
|
||||
カスタムのチャンネルアカウント ID には、ルーティング不一致を防ぐため OpenClaw 互換の正規形式(`[a-z0-9_-]`、英小文字、最大 64 文字、先頭は英小文字または数字)を必須にしています。
|
||||
ClawX には Tencent 公式の個人 WeChat チャンネルプラグインも同梱されており、Channels ページからアプリ内 QR フローで直接 WeChat を連携できます。
|
||||
|
||||
### ⏰ Cronベースの自動化
|
||||
AIタスクを自動的に実行するようスケジュール設定できます。トリガーを定義し、間隔を設定することで、手動介入なしにAIエージェントを24時間稼働させることができます。
|
||||
定期タスク画面では外部配信を「送信アカウント」と「受信先ターゲット」の 2 段階セレクターで設定できるようになりました。対応チャネルでは、受信先候補をチャネルのディレクトリ機能や既知セッション履歴から自動検出するため、`jobs.json` を手で編集する必要はありません。タスクのメッセージ入力欄でも、メインのチャット入力と同じインライン `/skill` トークン記法でスキルを挿入できるようになりました(選択中のエージェントに応じて読み込み)。スケジュールされたプロンプトから直接スキルを起動できます。スケジュール選択は**繰り返し**と**1回のみ**のタブに分かれました。繰り返しは毎時・毎日・平日・毎週・カスタム(生の cron)の頻度を時刻/曜日コントロール付きで選べ、1回のみは選択した日付(曜日を表示)と時刻に一度だけ実行します。1回のみのタスクは未来の時刻を指定する必要があり、実行後はランタイムにより自動的に削除されます。
|
||||
runtime が **今すぐ実行** を非同期で受け付ける場合、ClawX はトリガー確認をブロックせず、Cron カードに最新の完了結果が表示されるか、制限された停止条件に達するまで runtime 管理のジョブをバックグラウンド更新します。
|
||||
|
||||
|
||||
### 🧩 拡張可能なスキルシステム
|
||||
事前構築されたスキルでAIエージェントを拡張できます。統合スキルパネルからスキルの閲覧、インストール、管理が可能です。パッケージマネージャーは不要です。
|
||||
ClawX はドキュメント処理スキル(`pdf`、`xlsx`、`docx`、`pptx`)もフル内容で同梱し、起動時に管理スキルディレクトリ(既定 `~/.openclaw/skills`)へ自動配備し、初回インストール時に既定で有効化します。追加の同梱スキル(`find-skills`、`self-improving-agent`、`tavily-search`、`brave-web-search`、`bocha-skill`)も既定で有効化されますが、必要な API キーが未設定の場合は OpenClaw が実行時に設定エラーを表示します。
|
||||
Skills ページでは OpenClaw の複数ソース(管理ディレクトリ、workspace、追加スキルディレクトリ)から検出されたスキルを表示でき、各スキルの実際のパスを確認して実フォルダを直接開けます。
|
||||
|
||||
主な検索スキルで必要な環境変数:
|
||||
- `BRAVE_SEARCH_API_KEY`: `brave-web-search` 用
|
||||
- `TAVILY_API_KEY`: `tavily-search` 用(上流ランタイムで OAuth 対応の場合あり)
|
||||
- `BOCHA_API_KEY`: `bocha-skill` 用
|
||||
事前構築されたスキルでAIエージェントを拡張できます。統合 Skills ページはローカル優先で、管理ディレクトリや workspace のスキルをスキャンし、Gateway に依存せず有効/無効を切り替えられます。エンタープライズ拡張がある場合は、その拡張が提供する marketplace も表示できます。
|
||||
ClawX はドキュメント処理スキル(`pdf`、`xlsx`、`docx`、`pptx`)もフル内容で同梱し、起動時に管理スキルディレクトリ(既定 `~/.openclaw/skills`)へ自動配備し、初回インストール時に既定で有効化します。
|
||||
Skills ページでは OpenClaw の複数ソース(管理ディレクトリ、workspace、追加スキルディレクトリ)から検出されたスキルを表示でき、各スキルの実際のパスを確認して実フォルダを直接開けます。OpenClaw 同梱の bundled skill については、コミュニティ版ではパッケージにも表示にも `skill-creator` のみを残し、dev 起動時と packaged 起動時の両方で他の bundled skill を物理的に削除します。さらに、削除済み bundled skill の古い `openclaw.json` エントリも一緒に掃除します。
|
||||
cc-connect runtime が有効な場合、有効化されたローカル skills は app userData 配下の管理 Codex home にミラーされ、同梱 Codex agent がグローバル skill ディレクトリを読まずに同じ skill セットを使えます。
|
||||
|
||||
### 🔐 セキュアなプロバイダー統合
|
||||
複数のAIプロバイダー(OpenAI、Anthropicなど)に接続でき、資格情報はシステムのネイティブキーチェーンに安全に保存されます。OpenAI は API キーとブラウザ OAuth(Codex サブスクリプション)の両方に対応しています。
|
||||
複数のAIプロバイダー(OpenAI、Anthropic、Z.AI / GLMなど)に接続でき、資格情報はシステムのネイティブキーチェーンに安全に保存されます。OpenAI は API キーとブラウザ OAuth(Codex サブスクリプション)の両方に対応しています。
|
||||
開発者モードでは、専用の Image Generation ページで、独立した OpenAI 互換の画像生成エンドポイント(Base URL、API キー、`gpt-image-2` などのモデル名)を設定でき、画像生成だけ専用の `/v1/images/generations` サービスを使い、チャットは通常の OpenAI Provider のまま継続できます。
|
||||
OpenAI-compatible ゲートウェイを **Custom プロバイダー** で使う場合、**設定 → AI Providers → Provider 編集** でカスタム `User-Agent` を設定でき、互換性が必要なエンドポイントで有効です。
|
||||
プロバイダーの編集や切り替え時、ClawX は `input: ["text", "image"]` など既存のモデル単位の能力メタデータを保持します。新しく選択した Custom プロバイダーのモデルには OpenClaw onboarding と同等の画像入力推論を適用し、不明なモデルはテキスト専用として扱います。
|
||||
Custom プロバイダーのモデル行には明示的な `contextWindow` も書き込まれ(モデルファミリーから推定、例:`gpt-5.x` → 272k)、旧バージョンで保存された行は起動時に自動補完されます。これにより OpenClaw は長いセッションを "Context overflow" エラーになる前に圧縮できます。compaction 未設定の場合は `agents.defaults.compaction.mode = "safeguard"` と `reserveTokensFloor = 50000` が既定値として設定されますが、ユーザーが自分で設定したモデル行や圧縮設定が変更されることはありません(`reserveTokensFloor` が未設定の場合のみ補完されることがあります)。
|
||||
Z.AI(CN / Global)は OpenClaw 組み込みの `zai` プロバイダー(`ZAI_API_KEY`)に対応し、既定モデルは `glm-5.2` です。Code Plan プリセットで Coding Plan エンドポイント(`…/api/coding/paas/v4`)へ切り替え、通常 API(`…/api/paas/v4`)も利用できます。CN と Global は同じ OpenClaw ランタイムキーを共有するため同時追加できません。
|
||||
互換ゲートウェイで `/models` が認証以外の理由で使えない場合、ClawX は API キー検証時に軽量な `/chat/completions` または `/responses` プローブへ自動フォールバックします。
|
||||
|
||||
### 🌙 アダプティブテーマ
|
||||
ライトモード、ダークモード、またはシステム同期テーマ。ClawXはあなたの好みに自動的に適応します。
|
||||
@@ -126,6 +163,9 @@ Skills ページでは OpenClaw の複数ソース(管理ディレクトリ、
|
||||
### 🚀 自動起動設定
|
||||
**設定 → 通用** から **システム起動時に自動起動** を有効化すると、ログイン後に ClawX が自動的に起動します。
|
||||
|
||||
### 🔔 更新通知
|
||||
ClawX は起動時に新しいバージョンを自動確認できます。更新が見つかるとアプリ内通知を表示し、ダウンロードやインストールはユーザーが選択した後にのみ実行されます。
|
||||
|
||||
---
|
||||
|
||||
## はじめに
|
||||
@@ -164,9 +204,11 @@ ClawXを初めて起動すると、**セットアップウィザード**が以
|
||||
3. **スキルバンドル** – 一般的なユースケース向けの事前設定スキルを選択
|
||||
4. **検証** – メインインターフェースに入る前に設定をテスト
|
||||
|
||||
サポート対象のシステム言語がある場合、ウィザードはその言語を初期選択し、未対応の場合は英語にフォールバックします。
|
||||
|
||||
### プロキシ設定
|
||||
|
||||
ClawXには、Electron、OpenClaw Gateway、またはTelegramなどのチャネルがローカルプロキシクライアントを介してインターネットにアクセスする必要がある環境向けに、組み込みのプロキシ設定が含まれています。
|
||||
ClawXには、Electron、OpenClaw Gateway、任意の cc-connect/Codex runtime、またはTelegramなどのチャネルがローカルプロキシクライアントを介してインターネットにアクセスする必要がある環境向けに、組み込みのプロキシ設定が含まれています。
|
||||
|
||||
**設定 → ゲートウェイ → プロキシ**を開いて以下を設定します:
|
||||
|
||||
@@ -187,8 +229,12 @@ ClawXには、Electron、OpenClaw Gateway、またはTelegramなどのチャネ
|
||||
- `host:port`のみの値はHTTPとして扱われます。
|
||||
- 高度なプロキシフィールドが空の場合、ClawXは`プロキシサーバー`にフォールバックします。
|
||||
- プロキシ設定を保存すると、Electronのネットワーク設定が即座に再適用され、ゲートウェイが自動的に再起動されます。
|
||||
- cc-connect runtime モードでは、Codex 子プロセスが同じ `HTTP_PROXY`、`HTTPS_PROXY`、`ALL_PROXY`、バイパス環境値を継承します。
|
||||
- ClawXはTelegramが有効な場合、プロキシをOpenClawのTelegramチャネル設定にも同期します。
|
||||
- **設定 → 詳細 → 開発者** では **OpenClaw Doctor** を実行でき、`openclaw doctor --json` の診断出力をアプリ内で確認できます。
|
||||
- ClawXのプロキシが無効な状態では、Gatewayの通常再起動時に既存のTelegramチャネルプロキシ設定を保持します。
|
||||
- OpenClaw設定のTelegramプロキシを明示的に消したい場合は、プロキシ無効の状態で一度「保存」を実行してください。
|
||||
- **設定 → 詳細 → 開発者** の Runtime Doctor は、OpenClaw では `openclaw doctor --json` を実行します。cc-connect では同梱の `cc-connect doctor user-isolation` と `codex doctor --json` を組み合わせ、モード 0600 の監査レポートを ClawX 管理の runtime ディレクトリへ保存します。Doctor Fix は OpenClaw 専用です。
|
||||
- Windows のパッケージ版では、同梱された `openclaw` CLI/TUI は端末入力を安定させるため、同梱の `node.exe` エントリーポイント経由で実行されます。
|
||||
|
||||
---
|
||||
|
||||
@@ -196,58 +242,91 @@ ClawXには、Electron、OpenClaw Gateway、またはTelegramなどのチャネ
|
||||
|
||||
ClawXは、**デュアルプロセス + Host API 統一アクセス**構成を採用しています。Renderer は単一クライアント抽象を呼び出し、プロトコル選択とライフサイクルは Main が管理します:
|
||||
|
||||
```┌─────────────────────────────────────────────────────────────────┐
|
||||
│ ClawX デスクトップアプリ │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Electron メインプロセス │ │
|
||||
│ │ • ウィンドウ&アプリケーションライフサイクル管理 │ │
|
||||
│ │ • ゲートウェイプロセスの監視 │ │
|
||||
│ │ • システム統合(トレイ、通知、キーチェーン) │ │
|
||||
│ │ • 自動アップデートオーケストレーション │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ │ IPC(権威ある制御プレーン) │
|
||||
│ ▼ │
|
||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ │ React レンダラープロセス │ │
|
||||
│ │ • モダンなコンポーネントベースUI(React 19) │ │
|
||||
│ │ • Zustandによるステート管理 │ │
|
||||
│ │ • 統一 host-api/api-client 呼び出し │ │
|
||||
│ │ • リッチなMarkdownレンダリング │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────┬──────────────────────────────────┘
|
||||
Chat transport は active runtime に応じて切り替わりますが、Renderer の境界は 1 つに保たれます。OpenClaw Chat は Electron Main が所有する ACP stdio bridge を使用し、Renderer は型付き host event を受け取ってメモリ上の ACP timeline を描画します。cc-connect Chat は `RuntimeManager` から cc-connect BridgePlatform 経由で dispatch され、session history、progress、approval、generated media も同じ経路を通ります。両モードで Renderer は同じ Host API facade を使い、Codex を直接呼び出しません。非 Chat 機能も runtime provider 経由で dispatch され、OpenClaw 固有操作は OpenClaw adapter 内に限定されます。
|
||||
|
||||
別の会話やページを開いても、未完了の ACP 応答はストリーミングを継続します。完了前に戻ると最新のメモリ内 timeline が復元され、ライブ応答の表示が続きます。完了後は通常の ACP 履歴リプレイが引き続き唯一の正となります。
|
||||
|
||||
ACP の assistant ターンにはターン全体の所要時間が表示されます。ライブ計時はクライアントが観測した prompt ライフサイクルに従い、アプリ内を移動しても継続します。履歴の所要時間は Electron Main が範囲を限定した OpenClaw transcript のタイムスタンプから算出し、ACP リプレイですでに復元されたターンだけに付与します。
|
||||
|
||||
ACP Chat は標準 ACP resource を添付ファイルとして表示します。ユーザーが選択した画像は、ホバー時のオーバーレイにファイル名を表示するサムネイルとして描画され、その他の利用可能な添付カードはファイル名に続いて、淡色で省略可能なソースパスを表示します。現在の OpenClaw ACP adapter が assistant のメディアを省略した場合も、明示的な assistant の `MEDIA:` ディレクティブを、元のディレクティブを表示せずに添付カードとして復元できます。現在の workspace 外を含む既存のローカルファイル参照は、プレビューまたはオープンのたびに Electron Main で正確な session と generation に対して再検証されます。AI が生成したプレビュー可能なローカル添付ファイル(20 MB 以下の `.docx` と `.pptx` を含む)は、読み取り専用のアプリ内プレビューを主要操作として維持し、対応アプリで開く操作と Finder、エクスプローラー、またはシステムのファイルマネージャーで表示する操作を副次メニューから利用できます。ローカル HTML 添付ファイルでは、そのメニューの先頭項目がファイル URL を右側のウェブブラウザで開きます。ここでも Office プレビューには同じ制限があります。`.doc` と `.ppt` はシステムアプリで開く形式のままで、DOCX のページ区切りは Microsoft Word と異なる場合があり、PPTX のアニメーション、画面切り替え、メディア再生はサポートされません。対応アプリの検出は macOS と Windows のみで利用でき、Linux または検出失敗時には通知せず、ファイルの場所を表示する操作だけに切り替わります。それ以外のローカルファイル(20 MB を超える Office ファイルを含む)はユーザーのクリック後にシステムアプリで開かれます。リモートの HTTP/HTTPS 添付ファイルはクリック後に外部で開かれます。通常の文章内にある単独またはインラインのパスは添付ファイルとして扱われません。
|
||||
|
||||
ACP Chat は、runtime が画像生成メディアを信頼できる構造化メディアとして配信した場合に、生成画像のプレビューも表示できます。信頼できる OpenClaw internal-UI 配信と画像生成タスクに関連付けられた最終返信では、テキストのみの失敗説明を含む元のユーザー向け完了テキストを保持し、汎用の画像キャプションへ置き換えません。OpenClaw の履歴リプレイ中は、同じセッションで画像生成タスク開始が記録されている場合に限り、assistant の画像 `MEDIA:` マーカーがインライン画像表示へ昇格されます。ClawX は Renderer から任意にファイルシステムへアクセスするのではなく、Electron Main のホストメディア処理を通じてプレビューを読み込みます。標準 ACP の画像と resource コンテンツは引き続き推奨パスであり、そのまま描画されます。
|
||||
|
||||
### ACP ファイルアクティビティのセマンティクス
|
||||
|
||||
- ファイルアクティビティは、成功して完了した OpenClaw の `write`、`edit`、`apply_patch` 呼び出しから投影されます。ツールの認識方法は公式 OpenClaw Chat UI に準拠し、完了した呼び出しだけに絞る処理は ClawX 固有です。
|
||||
- 作成・変更されたアクティビティ行は、プレビュー可能な assistant 添付ファイルと同じファイルカードと**アプリで開く**メニューを使い、状態表示と利用可能な `+/-` 集計も保持します。HTML ファイルでは、メニューの先頭項目がローカルファイル URL を右側の**ウェブブラウザ**で開き、そのタブを有効にします。削除された行には **Changes** 操作だけを残します。アプリ一覧、選択アプリで開く操作、ファイル位置の表示は、workspace ルートと相対パスから Electron Main が毎回個別に再検証します。ツール由来のパスが添付ファイルに変換されたり、Renderer に正規化済みのネイティブパスが渡されたりすることはありません。
|
||||
- `write` はツールが宣言したとおり、作成および全行追加の差分として表示されます。対象パスがすでに存在する可能性がある場合も同様です。
|
||||
- **Changes** は、ツールが宣言したアクティビティを時系列に並べたセッション単位の記録です。Git の出力でも、検証済みソースベースラインに対する差分でもありません。
|
||||
- 各ファイルについて、Changes はアシスタントの各ターンに最大 1 つの diff エディターを表示します。安全に連結できるフラグメントは合成し、独立したフラグメントは 1 つのエディターに連結しますが、完全なファイルベースラインとの差分であるとはみなしません。
|
||||
- シェルコマンド、スクリプト、ユーザー、IDE による副作用は検出されません。
|
||||
- 完全な ACP リプレイからは記録済みのファイルアクティビティを復元できます。リプレイが不完全な場合、ClawX はフォールバック推論で欠落したアクティビティを補いません。
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────┐
|
||||
│ ClawX デスクトップアプリ │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Electron メインプロセス │ │
|
||||
│ │ • ウィンドウ&アプリケーションライフサイクル管理 │ │
|
||||
│ │ • ゲートウェイプロセスの監視 │ │
|
||||
│ │ • システム統合(トレイ、通知、キーチェーン) │ │
|
||||
│ │ • 自動アップデートオーケストレーション │ │
|
||||
│ └──────────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ │ IPC(権威ある制御プレーン) │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||
│ │ React レンダラープロセス │ │
|
||||
│ │ • モダンなコンポーネントベースUI(React 19) │ │
|
||||
│ │ • Zustandによるステート管理 │ │
|
||||
│ │ • 統一 host-api/api-client 呼び出し │ │
|
||||
│ │ • リッチなMarkdownレンダリング │ │
|
||||
│ └──────────────────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────┬─────────────────────────────────────┘
|
||||
│
|
||||
│ Main管理のトランスポート戦略
|
||||
│(WS優先、HTTP次点、IPCフォールバック)
|
||||
│ 型付き IPC リクエスト
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Host API と Main プロキシ層 │
|
||||
│ │
|
||||
│ • hostapi:fetch(Mainプロキシ、CORS回避) │
|
||||
│ • gateway:httpProxy(RendererはGateway HTTPに直アクセスしない) │
|
||||
│ • 統一エラーマッピングとリトライ/バックオフ │
|
||||
│ Main Host Services と Runtime Manager │
|
||||
│ │
|
||||
│ • host:invoke 型付きサービスディスパッチ │
|
||||
│ • 設定、ファイル、セッション、スキル、プロバイダー、診断サービス │
|
||||
│ • Runtime 選択、transport、プロセス監視を所有 │
|
||||
└──────────────────────────────┬──────────────────────────────────┘
|
||||
│
|
||||
│ WS / HTTP / IPC フォールバック
|
||||
│ Main 所有 WebSocket
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ OpenClaw ゲートウェイ │
|
||||
│ │
|
||||
│ • AIエージェントランタイムとオーケストレーション │
|
||||
│ • メッセージチャネル管理 │
|
||||
│ • スキル/プラグイン実行環境 │
|
||||
│ • プロバイダー抽象化レイヤー │
|
||||
│ OpenClaw Gateway 経路(図示) │
|
||||
│ │
|
||||
│ • AIエージェントランタイムとオーケストレーション │
|
||||
│ • メッセージチャネル管理 │
|
||||
│ • スキル/プラグイン実行環境 │
|
||||
│ • プロバイダー抽象化レイヤー │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
### 設計原則
|
||||
|
||||
- **プロセス分離**: AIランタイムは別プロセスで動作し、重い計算処理中でもUIの応答性を確保します
|
||||
- **フロントエンド呼び出しの単一入口**: Renderer は host-api/api-client を通じて呼び出し、下位プロトコルに依存しません
|
||||
- **Mainによるトランスポート制御**: WS/HTTP の選択と IPC フォールバックを Main で一元管理します
|
||||
- **Mainによるトランスポート制御**: OpenClaw ACP/Gateway transport と cc-connect BridgePlatform dispatch は Electron Main が所有し、Renderer は型付き IPC で Main と通信します
|
||||
- **拡張 IPC コントリビューション**: Main プロセス拡張は HTTP route ではなく、型付き IPC レジストリを通じて host-api action を提供します
|
||||
- **グレースフルリカバリ**: 再接続・タイムアウト・バックオフで一時的障害を自動処理します
|
||||
- **セキュアストレージ**: APIキーや機密データは、OSのネイティブセキュアストレージ機構を活用します
|
||||
- **CORSセーフ設計**: ローカルHTTPはMainプロキシ経由とし、Renderer側CORS問題を回避します
|
||||
- **CORSセーフ設計**: Renderer はローカル Gateway や Host API HTTP エンドポイントを直接呼び出しません
|
||||
|
||||
### プロセスモデルと Gateway トラブルシューティング
|
||||
|
||||
- ClawX は Electron アプリのため、**1つのアプリインスタンスでも複数プロセス(main/renderer/zygote/utility)が表示される**のが正常です。
|
||||
- 単一起動保護は Electron のロックに加え、`~/.clawx/locks` 配下のインストール横断 writer lock も使用します。ClawX は共有データ初期化、移行、runtime、scheduler の起動前にこのロックを取得し、所有権を確認できない場合は起動を拒否します。
|
||||
- ローリングアップグレード中に旧版/新版が混在すると、単一起動保護の挙動が非対称になる場合があります。安定運用のため、デスクトップクライアントは可能な限り同一バージョンへ揃えてください。
|
||||
- ただし OpenClaw Gateway の待受は常に**単一**であるべきです。`127.0.0.1:18789` を Listen しているプロセスは1つだけです。
|
||||
- Gateway の readiness は `system-presence`、`health`、`status` などの OpenClaw コア信号を基準にし、memory、Dreams、チャネルの失敗はグローバルな Gateway 障害ではなく capability degradation として表示します。
|
||||
- Listen プロセスの確認例:
|
||||
- macOS/Linux: `lsof -nP -iTCP:18789 -sTCP:LISTEN`
|
||||
- Windows (PowerShell): `Get-NetTCPConnection -LocalPort 18789 -State Listen`
|
||||
- ウィンドウの閉じるボタン(`X`)は既定でトレイへ最小化する動作で、完全終了ではありません。完全終了する場合はトレイメニューの **Quit ClawX** を使用してください。
|
||||
|
||||
---
|
||||
|
||||
@@ -271,16 +350,19 @@ AI を開発ワークフローに統合できます。エージェントを使
|
||||
|
||||
### 前提条件
|
||||
|
||||
- **Node.js**: 22以上(LTS推奨)
|
||||
- **Node.js**: 対応するメジャー系列の 22.22.3以上、24.15.0以上、または25.9.0以上(Node 24 LTS推奨)
|
||||
- **パッケージマネージャー**: pnpm 9以上(推奨)またはnpm
|
||||
- **Linux(Ubuntu/Debian)**: Electron を実行する前に、必要なシステムライブラリをインストールしてください:
|
||||
```bash
|
||||
sudo apt-get install -y libnss3 libgtk-3-0 libxss1 libxtst6 libatspi2.0-0 libnotify4 xdg-utils
|
||||
```
|
||||
Ubuntu 24.04以降では、一部のパッケージに `t64` サフィックスが付いています。上記コマンドを実行すると `apt` が自動的に適切なバリアントを選択します。
|
||||
|
||||
### プロジェクト構成
|
||||
|
||||
```ClawX/
|
||||
├── electron/ # Electron メインプロセス
|
||||
│ ├── api/ # メイン側 API ルーターとハンドラー
|
||||
│ │ └── routes/ # RPC/HTTP プロキシのルートモジュール
|
||||
│ ├── services/ # Provider/Secrets/ランタイムサービス
|
||||
│ ├── services/ # 型付き Host API、Provider/Secrets/ランタイムサービス
|
||||
│ │ ├── providers/ # provider/account モデル同期ロジック
|
||||
│ │ └── secrets/ # OS キーチェーンと秘密情報管理
|
||||
│ ├── shared/ # 共通 Provider スキーマ/定数
|
||||
@@ -297,16 +379,19 @@ AI を開発ワークフローに統合できます。エージェントを使
|
||||
│ ├── i18n/ # ローカライズリソース
|
||||
│ └── types/ # TypeScript 型定義
|
||||
├── tests/
|
||||
│ ├── e2e/ # Playwright による Electron E2E スモークテスト
|
||||
│ └── unit/ # Vitest ユニット/統合寄りテスト
|
||||
├── resources/ # 静的アセット(アイコン、画像)
|
||||
└── scripts/ # ビルド/ユーティリティスクリプト
|
||||
```
|
||||
### 利用可能なコマンド
|
||||
|
||||
cc-connect の実環境検証はローカル env ファイルを読み込めますが、リポジトリ内の認証情報ファイルは gitignore されている必要があります。リポジトリ外の `--env-file` パスは利用でき、レポートには書き込まれません。`.env.cc-connect.local.example` は `.env.cc-connect.local` のフィールドテンプレートです。
|
||||
|
||||
```bash
|
||||
# 開発
|
||||
pnpm run init # 依存関係のインストール + uvのダウンロード
|
||||
pnpm dev # ホットリロードで起動
|
||||
pnpm run init # 依存関係のインストール + バンドルバイナリ(uv、agent-browser)のダウンロード
|
||||
pnpm dev # ホットリロードで起動(不足時は同梱スキルを自動準備)
|
||||
|
||||
# コード品質
|
||||
pnpm lint # ESLintを実行
|
||||
@@ -314,15 +399,65 @@ pnpm typecheck # TypeScriptの型チェック
|
||||
|
||||
# テスト
|
||||
pnpm test # ユニットテストを実行
|
||||
pnpm run test:e2e # Electron E2E スモークテストを実行
|
||||
pnpm run test:e2e:cc-connect:codex-oauth-lifecycle # 実認証情報なしで cc-connect Codex OAuth Host API の status/import/logout を検証
|
||||
CLAWX_REAL_OAUTH_E2E=1 CLAWX_REAL_CODEX_AUTH_JSON="$HOME/.codex/auth.json" pnpm run test:e2e:cc-connect:real-oauth # 実 OAuth tool execution と Chat execution graph を検証
|
||||
pnpm run test:e2e:headed # 表示付きウィンドウで Electron E2E を実行
|
||||
pnpm run comms:replay # 通信リプレイ指標を算出
|
||||
pnpm run comms:baseline # 通信ベースラインを更新
|
||||
pnpm run comms:compare # リプレイ指標をベースライン閾値と比較
|
||||
pnpm run verify:cc-connect:local-real # ローカル cc-connect 実環境検証の事前レポートを書き出す
|
||||
pnpm run verify:cc-connect:local-real:run # 安全なローカル cc-connect 実環境検証を実行してレポートを書き出す
|
||||
pnpm run verify:cc-connect:local-real:oauth # CLAWX_REAL_CODEX_AUTH_JSON に完全な refresh token フィールドがある場合、開発版 cc-connect の実 OAuth 総合スモークも実行
|
||||
pnpm run verify:cc-connect:local-real:oauth-all # CLAWX_REAL_CODEX_AUTH_JSON に完全な refresh token フィールドがある場合、開発版とパッケージ版 cc-connect の実 OAuth スモークも実行
|
||||
pnpm run verify:cc-connect:local-real:api-key # ローカル OpenAI-compatible API-key chat/abort スモークを実行し、認証情報がある場合は実 OpenAI API-key スモークも実行
|
||||
pnpm run verify:cc-connect:local-real:feishu # 認証情報と CLAWX_REAL_CODEX_AUTH_JSON がある場合に実 Feishu/Lark ライフサイクルスモークも実行
|
||||
pnpm run verify:cc-connect:local-real:feishu-inbound # サンドボックス tenant fixture が有効な場合に実 Feishu/Lark inbound marker スモークも実行
|
||||
pnpm run verify:cc-connect:local-real:scheduled-cron # 実 native exec cron を実行し、Codex auth がある場合は public cc-connect session history で native prompt scheduling も検証
|
||||
pnpm run verify:cc-connect:local-real:all # 利用可能なローカル cc-connect 実環境検証をすべて実行し、外部 gate handoff を書き出す
|
||||
pnpm run verify:cc-connect:local-real:all-strict # リリース候補検証では全実認証情報と runtime parity coverage の PASS を必須にし、失敗前にも handoff を書き出す
|
||||
pnpm run verify:cc-connect:local-real:replacement-ready # replacement readiness を必須にし、不足認証情報を別の事前失敗にはしない。失敗前にも handoff を書き出す
|
||||
pnpm run verify:cc-connect:local-real:replacement-ready:check # 同じ readiness gate を実行し、前回のレポート成果物は上書きしない
|
||||
pnpm run verify:cc-connect:local-real:packaged-oauth # CLAWX_REAL_CODEX_AUTH_JSON に完全な refresh token フィールドがある場合、パッケージ版 cc-connect の実 OAuth スモークも実行
|
||||
pnpm run verify:cc-connect:local-real:external-gates:check # 残りの required external gates を非破壊で確認し、レポート成果物は上書きしない
|
||||
pnpm run verify:cc-connect:local-real:external-gates # 残りの required external gates のみを実行し、3件すべて PASS の場合だけ成功
|
||||
pnpm run verify:cc-connect:local-real:handoff # 残りの外部 gate 向けに認証情報を含まない handoff checklist を生成
|
||||
|
||||
# レポートは artifacts/cc-connect/local-real-validation-report.{json,md} に出力されます。
|
||||
# :all、:all-strict、:replacement-ready、:external-gates、または :handoff は artifacts/cc-connect/local-real-external-gates.{md,json} に外部 gate handoff を出力します。
|
||||
# JSON handoff は machine-readable で、sanitize 済みの status、env var 名、command、安全メモのみを含みます。
|
||||
# runtimeMatrixStatus は pass/partial/fail の coverage と hard gate の終了状態を分けて表示します。
|
||||
# --no-write、replacement-ready:check、または external-gates:check は非破壊の gate check に使えます。不足 precondition と次の command は秘密値なしで表示されます。
|
||||
# validationGaps はローカル hard gate の不足と full parity に必要な follow-up evidence gap を分けて記録します。
|
||||
# partial レポートには秘密値を含まない後続コマンドの Next Actions が含まれます。
|
||||
# 実認証情報は、未追跡かつ gitignore 済みの .env.cc-connect.local、--env-file=<path>、
|
||||
# または CLAWX_REAL_ENV_FILE / CLAWX_REAL_ENV_FILES で渡せます。明示的な process env が優先されます。
|
||||
# API-key スモークでは、デフォルトモデルが利用できない場合に CLAWX_REAL_OPENAI_MODEL を設定できます。
|
||||
|
||||
# ビルド&パッケージ
|
||||
pnpm run build:vite # フロントエンドのみビルド
|
||||
pnpm build # フルプロダクションビルド(パッケージアセット含む)
|
||||
pnpm package # 現在のプラットフォーム向けにパッケージ化
|
||||
pnpm package # 現在のプラットフォーム向けにパッケージ化(同梱プリインストールスキルを含む)
|
||||
pnpm package:mac # macOS向けにパッケージ化
|
||||
pnpm package:win # Windows向けにパッケージ化
|
||||
pnpm package:linux # Linux向けにパッケージ化
|
||||
pnpm run verify:runtime-bundles # ダウンロード済み cc-connect/Codex bundle の manifest とバイナリを検証
|
||||
pnpm run verify:packaged-runtime-resources -- --resources=<path> --platform=<darwin|win32|linux> --arch=<x64|arm64> # 最終 Electron runtime resources を検証
|
||||
pnpm run smoke:cc-connect:packaged # ネイティブ unpacked app を起動し、cc-connect の起動/状態/Cron/Doctor/ロールバック/クリーンアップを検証
|
||||
```
|
||||
|
||||
ヘッドレス Linux では Electron テストに表示サーバーが必要です。`xvfb-run -a pnpm run test:e2e` を利用してください。
|
||||
|
||||
### 通信回帰チェック
|
||||
|
||||
PR が通信経路(Gateway イベント、ACP Chat bridge の送受信フロー、Channel 配信、トランスポートのフォールバック)に触れる場合は、次を実行してください。
|
||||
|
||||
```bash
|
||||
pnpm run comms:replay
|
||||
pnpm run comms:compare
|
||||
```
|
||||
|
||||
CI の `comms-regression` が必須シナリオと閾値を検証します。
|
||||
### 技術スタック
|
||||
|
||||
| レイヤー | 技術 |
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
English | <a href="README.zh-CN.md">简体中文</a> | <a href="README.ja-JP.md">日本語</a>
|
||||
English | <a href="README.zh-CN.md">简体中文</a> | <a href="README.ja-JP.md">日本語</a> | <a href="README.ru-RU.md">Русский</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -43,6 +43,8 @@ Whether you're automating workflows, managing AI-powered channels, or scheduling
|
||||
|
||||
ClawX comes pre-configured with best-practice model providers and natively supports Windows as well as multi-language settings. Of course, you can also fine-tune advanced configurations via **Settings → Advanced → Developer Mode**.
|
||||
|
||||
<p align="center"><strong style="font-size:1.1em; text-decoration: underline;">For a full enterprise edition, dedicated service support, or tailored deployment guidance for your business scenario, contact us at <a href="mailto:public@valuecell.ai">public@valuecell.ai</a>.</strong></p>
|
||||
|
||||
---
|
||||
## Screenshot
|
||||
|
||||
@@ -81,8 +83,9 @@ Building AI agents shouldn't require mastering the command line. ClawX was desig
|
||||
| Complex CLI setup | One-click installation with guided setup wizard |
|
||||
| Configuration files | Visual settings with real-time validation |
|
||||
| Process management | Automatic gateway lifecycle management |
|
||||
| App updates | Startup update checks with a prompt before downloading or installing |
|
||||
| Multiple AI providers | Unified provider configuration panel |
|
||||
| Skill/plugin installation | Built-in skill marketplace and management |
|
||||
| Skill/plugin installation | Local-first skill management with optional extension-provided marketplace |
|
||||
|
||||
### OpenClaw Inside
|
||||
|
||||
@@ -90,6 +93,18 @@ ClawX is built directly upon the official **OpenClaw** core. Instead of requirin
|
||||
|
||||
We are committed to maintaining strict alignment with the upstream OpenClaw project, ensuring that you always have access to the latest capabilities, stability improvements, and ecosystem compatibility provided by the official releases.
|
||||
|
||||
When Developer Mode is enabled and OpenClaw is the active runtime, the sidebar also provides a native Dreams page for OpenClaw memory review, dream diary inspection, and basic maintenance actions. The full upstream OpenClaw Dreams UI remains available from that page when deeper diagnostics are needed.
|
||||
|
||||
ClawX also includes a runtime abstraction layer. OpenClaw remains the default runtime and rollback path, while **Settings → Gateway → Runtime** can switch to an optional bundled `cc-connect` runtime. Packaged builds include both the cc-connect binary and the native OpenAI Codex CLI bundle in app resources; runtime startup does not depend on global installs, PATH binaries, or app-time downloads. ClawX keeps upgrade-stable app config, credentials, runtime data, skills, and workspaces under `~/.clawx` (or `CLAWX_DATA_HOME`) instead of modifying `~/.cc-connect`. GUI chat connects through cc-connect BridgePlatform with Codex as the project agent; managed projects use cc-connect's Codex app-server backend over stdio so live tool progress can drive the shared Chat execution graph directly. When public cc-connect history omits tool packets for a channel-originated session, ClawX supplements that history from matching local Codex transcripts constrained to the owning Agent's workspace. Approval buttons and cc-connect card choices are rendered in that graph, and responses return through cc-connect's public `card_action` protocol. Runtime-generated image, file, audio, and video packets also return through BridgePlatform and remain visible as Chat attachments. Each Agent defaults to Full Auto and can independently select Ask for approval (`suggest`) in Agent model/runtime settings. New agents use `~/.clawx/workspaces/agents/<id>`; existing OpenClaw workspaces can be reused by reference without being moved or owned by ClawX. Provider/model selections, native cron tasks, and enabled skills are synchronized into the managed cc-connect/Codex runtime.
|
||||
|
||||
Agent and channel settings are canonical under `~/.clawx`. While cc-connect is active, saving them does not rewrite `~/.openclaw/openclaw.json`; switching back to OpenClaw rebuilds that compatibility projection before the Gateway starts.
|
||||
|
||||
In cc-connect mode, Codex provider sync supports OpenAI API key, OpenAI OAuth/Codex, Ollama, and Custom OpenAI-compatible providers that expose the Responses API. Custom provider headers are written as environment-variable references so secrets and session headers are not persisted in managed config files. Custom providers configured for Chat Completions are reported as unsupported before chat delivery because Codex accepts the Responses wire API for this path.
|
||||
|
||||
Each OAuth provider account has an isolated managed `CODEX_HOME`. An existing user-global Codex login is never adopted during runtime startup; importing it requires the explicit Codex OAuth import action for the selected account.
|
||||
|
||||
cc-connect also owns messaging platform bridges. When cc-connect is the active runtime, channel status probes are routed through the runtime abstraction instead of the OpenClaw Gateway, configured channel accounts are mirrored into the cc-connect project that owns their bound agent, and channel saves/deletes reload the managed cc-connect config through its Management API so platform changes take effect without a full runtime restart when possible. The Developer Mode sidebar page shortcut opens cc-connect Web Admin, while the OpenClaw Dreams shortcut remains OpenClaw-only.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
@@ -98,28 +113,49 @@ We are committed to maintaining strict alignment with the upstream OpenClaw proj
|
||||
Complete the entire setup—from installation to your first AI interaction—through an intuitive graphical interface. No terminal commands, no YAML files, no environment variable hunting.
|
||||
|
||||
### 💬 Intelligent Chat Interface
|
||||
Communicate with AI agents through a modern chat experience. Support for multiple conversation contexts, message history, rich content rendering with Markdown, and direct `@agent` routing in the main composer for multi-agent setups.
|
||||
Communicate with AI agents through a modern chat experience. Support for multiple conversation contexts, message history, rich content rendering with Markdown (including GitHub-flavored tables and KaTeX-powered LaTeX math: `$inline$`, `$$block$$`, `\(inline\)`, and `\[block\]`), and direct `@agent` routing in the main composer for multi-agent setups.
|
||||
Skills you insert from the composer appear as `/skill-name` chips; click a chip to open the preview sidebar and read that skill's `SKILL.md`.
|
||||
When you target another agent with `@agent`, ClawX switches into that agent's own conversation context directly instead of relaying through the default agent. Agent workspaces stay separate by default, and stronger isolation depends on OpenClaw sandbox settings.
|
||||
The session sidebar is workspace-first: the default workspace stays at the top, other workspaces sort naturally, and each workspace can collapse or load more sessions. A row shows a spinner while the AI is replying, a blue dot when an unseen reply finishes, and its relative activity time after the conversation is opened; hovering still reveals row actions. Imported workspaces can be renamed from their sidebar header; the custom name is reflected in the chat composer while hovering the header still reveals the filesystem path. When available, a new chat inherits the selected conversation's workspace while remaining editable until first send. Editable new or unbound chats expose the composer workspace chip as a small menu that lists recent and known-session workspaces, returns to the default workspace, or chooses another folder. If a saved workspace folder was moved or deleted, Chat pauses session creation and prompts you to choose an existing folder instead of repeatedly retrying the missing path. Unavailable non-default groups are marked in the sidebar and can be removed after confirmation; this permanently deletes every session in that group. Synthetic OpenClaw UUID-date fallback titles are treated as missing only when they match the session ID, then replaced with the conversation's first user prompt instead of being persisted as the session name.
|
||||
Each agent can also override its own `provider/model` runtime setting; agents without overrides continue inheriting the global default model.
|
||||
|
||||
The Workspace and Preview tabs in Chat's right panel provide read-only previews for `.docx` and `.pptx` files. Legacy `.doc` and `.ppt` files continue to open through the operating system instead of inline. DOCX pagination may differ from Microsoft Word, and PPTX previews do not support animations, transitions, or media playback. Office files larger than 20 MB are not previewed inline.
|
||||
|
||||
### Single-Page Web Browser
|
||||
The Chat right panel has four tabs: Workspace, Preview, Changes, and Web Browser. Web Browser lazily creates one live page and keeps it running when you close the panel, select another panel tab, switch chat sessions, or visit another ClawX route; hidden pages may continue scripts, network activity, audio, and resource use. Its dedicated persistent session retains cookies and site storage across app restarts, but every new app run starts at `about:blank` without restoring the previous URL, page state, or navigation history. When a page provides a favicon, it appears beside the title; a same-size placeholder keeps the title aligned while no favicon is available, and the icon slot is hidden while editing the address. There are no additional browser tabs or windows, bookmarks, persisted history, password manager, or autofill management.
|
||||
|
||||
Top-level navigation accepts HTTP, HTTPS, and explicitly entered standard `file:///` URLs. Plain filesystem paths and other protocols are rejected. Opening a local file exposes its readable content to the embedded page under normal Chromium security rules, and using **Open in System Browser** for a `file:` URL may launch the OS-associated application instead of a browser. Allowed popup targets replace the current page rather than creating a child window; this same-page fallback cannot preserve `window.opener`, returned window handles, initially blank scripted popups, or full POST-body, referrer, named-window, and window-feature behavior.
|
||||
|
||||
Downloads keep Electron and the operating system defaults. Depending on the platform, this may present a native Save dialog and require user interaction; ClawX does not choose a custom path or provide download progress, history, or management UI. Camera and microphone access uses a native Allow/Deny prompt for every request and is never remembered. Clipboard access is allowed, while geolocation, display capture, notifications, and other permissions are denied.
|
||||
|
||||
**Clear Cookies** removes cookies for every origin in the browser session while preserving cache and site storage. **Clear Site Data** removes HTTP/Chromium cache, Cache Storage, Local Storage, IndexedDB, and Service Workers for every origin while preserving cookies and downloaded files. Browser traffic follows Electron/Chromium system-proxy resolution; ClawX client proxy settings are not synchronized to this browser session, and changing them does not reconfigure it.
|
||||
|
||||
### 📡 Multi-Channel Management
|
||||
Configure and monitor multiple AI channels simultaneously. Each channel operates independently, allowing you to run specialized agents for different tasks.
|
||||
Each channel now supports multiple accounts, per-account agent binding, and switching the channel default account directly from the Channels page.
|
||||
For custom channel account IDs, ClawX enforces OpenClaw-compatible canonical IDs (`[a-z0-9_-]`, lowercase, max 64 chars, must start with a letter/number) to prevent routing mismatches.
|
||||
ClawX now also bundles Tencent's official personal WeChat channel plugin, so you can link WeChat directly from the Channels page with an in-app QR flow.
|
||||
|
||||
### ⏰ Cron-Based Automation
|
||||
Schedule AI tasks to run automatically. Define triggers, set intervals, and let your AI agents work around the clock without manual intervention.
|
||||
The Cron page now lets you configure external delivery directly in the task form with separate sender-account and recipient-target selectors. For supported channels, recipient targets are discovered automatically from channel directories or known session history, so you no longer need to edit `jobs.json` by hand. The task message field also supports inserting skills with the same inline `/skill` token syntax as the main chat composer (scoped to the selected agent), so scheduled prompts can trigger skills directly. The schedule picker is split into **Recurring** and **Once** tabs: Recurring offers Hourly, Daily, Weekdays, Weekly, and Custom (raw cron) frequencies with inline time/weekday controls, while Once runs the task a single time at a chosen date (with weekday shown) and time. One-time tasks must be scheduled for a future moment and are automatically removed by the runtime once they finish.
|
||||
When a runtime accepts **Run Now** asynchronously, ClawX keeps the trigger acknowledgement non-blocking and refreshes the runtime-owned job in the background until its latest completion result appears on the Cron card or a bounded stop condition is reached.
|
||||
|
||||
|
||||
### 🧩 Extensible Skill System
|
||||
Extend your AI agents with pre-built skills. Browse, install, and manage skills through the integrated skill panel—no package managers required.
|
||||
ClawX also pre-bundles full document-processing skills (`pdf`, `xlsx`, `docx`, `pptx`), deploys them automatically to the managed skills directory (default `~/.openclaw/skills`) on startup, and enables them by default on first install. Additional bundled skills (`find-skills`, `self-improving-agent`, `tavily-search`, `brave-web-search`, `bocha-skill`) are also enabled by default; if required API keys are missing, OpenClaw will surface configuration errors in runtime.
|
||||
The Skills page can display skills discovered from multiple OpenClaw sources (managed dir, workspace, and extra skill dirs), and now shows each skill's actual location so you can open the real folder directly.
|
||||
|
||||
Environment variables for bundled search skills:
|
||||
- `BRAVE_SEARCH_API_KEY` for `brave-web-search`
|
||||
- `TAVILY_API_KEY` for `tavily-search` (OAuth may also be supported by upstream skill runtime)
|
||||
- `BOCHA_API_KEY` for `bocha-skill`
|
||||
- `find-skills` and `self-improving-agent` do not require API keys
|
||||
Extend your AI agents with pre-built skills. The integrated Skills page is local-first: it scans managed/workspace skill directories, lets you enable or disable skills without depending on the Gateway, and can optionally expose an extension-provided marketplace in enterprise builds.
|
||||
ClawX also pre-bundles full document-processing skills (`pdf`, `xlsx`, `docx`, `pptx`), deploys them automatically to the managed skills directory (default `~/.openclaw/skills`) on startup, and enables them by default on first install.
|
||||
The Skills page can display skills discovered from multiple OpenClaw sources (managed dir, workspace, and extra skill dirs), and now shows each skill's actual location so you can open the real folder directly. For bundled OpenClaw skills, community builds now ship and expose only `skill-creator`; non-allowlisted bundled skills are physically trimmed in both dev and packaged startup, and any stale `openclaw.json` entries left behind for those removed bundled skills are pruned.
|
||||
When cc-connect runtime is active, enabled local skills are mirrored into the managed Codex home under app user data so the bundled Codex agent can use the same skill set without reading global skill directories.
|
||||
|
||||
### 🔐 Secure Provider Integration
|
||||
Connect to multiple AI providers (OpenAI, Anthropic, and more) with credentials stored securely in your system's native keychain. OpenAI supports both API key and browser OAuth (Codex subscription) sign-in.
|
||||
Connect to multiple AI providers (OpenAI, Anthropic, Z.AI / GLM, and more) with credentials stored securely in your system's native keychain. OpenAI supports both API key and browser OAuth (Codex subscription) sign-in.
|
||||
In developer mode, the dedicated Image Generation page supports an independent OpenAI-compatible image-generation endpoint (Base URL, API key, and model name such as `gpt-image-2`) so image generation can use a dedicated `/v1/images/generations` service while chat continues using the normal OpenAI provider.
|
||||
For **Custom** providers used with OpenAI-compatible gateways, you can set a custom `User-Agent` in **Settings → AI Providers → Edit Provider** for compatibility-sensitive endpoints.
|
||||
When you edit or switch providers, ClawX preserves existing per-model capability metadata such as `input: ["text", "image"]`. Newly selected Custom-provider models use OpenClaw onboarding-compatible image-input inference, with unknown models defaulting to text-only.
|
||||
Custom-provider model rows also receive an explicit `contextWindow` (inferred from the model family, e.g. `gpt-5.x` → 272k), and rows saved by older versions are backfilled on startup, so OpenClaw can compact long sessions before they fail with "Context overflow" errors. When you have no compaction config, ClawX seeds `agents.defaults.compaction.mode = "safeguard"` and `reserveTokensFloor = 50000`; rows or configs you authored yourself are never modified (except a missing `reserveTokensFloor` may be backfilled).
|
||||
Z.AI (CN / Global) maps to OpenClaw's built-in `zai` provider (`ZAI_API_KEY`). Default model is `glm-5.2`. Use the Code Plan preset for Coding Plan endpoints (`…/api/coding/paas/v4`) or the normal API endpoints (`…/api/paas/v4`); CN and Global are mutually exclusive because they share one OpenClaw runtime key.
|
||||
When a compatible gateway rejects `/models` for non-auth reasons, ClawX automatically falls back to a lightweight `/chat/completions` or `/responses` probe during API key validation.
|
||||
|
||||
### 🌙 Adaptive Theming
|
||||
Light mode, dark mode, or system-synchronized themes. ClawX adapts to your preferences automatically.
|
||||
@@ -127,6 +163,9 @@ Light mode, dark mode, or system-synchronized themes. ClawX adapts to your prefe
|
||||
### 🚀 Startup Launch Control
|
||||
In **Settings → General**, you can enable **Launch at system startup** so ClawX starts automatically after login.
|
||||
|
||||
### 🔔 Update Prompts
|
||||
ClawX can automatically check for new versions on startup. When an update is available, it shows an in-app prompt; downloading and installing only happen after you choose the action.
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
@@ -165,12 +204,14 @@ When you launch ClawX for the first time, the **Setup Wizard** will guide you th
|
||||
3. **Skill Bundles** – Select pre-configured skills for common use cases
|
||||
4. **Verification** – Test your configuration before entering the main interface
|
||||
|
||||
The wizard preselects your system language when it is supported, and falls back to English otherwise.
|
||||
|
||||
> Note for Moonshot (Kimi): ClawX keeps Kimi web search enabled by default.
|
||||
> When Moonshot is configured, ClawX also syncs Kimi web search to the China endpoint (`https://api.moonshot.cn/v1`) in OpenClaw config.
|
||||
|
||||
### Proxy Settings
|
||||
|
||||
ClawX includes built-in proxy settings for environments where Electron, the OpenClaw Gateway, or channels such as Telegram need to reach the internet through a local proxy client.
|
||||
ClawX includes built-in proxy settings for environments where Electron, the OpenClaw Gateway, the optional cc-connect/Codex runtime, or channels such as Telegram need to reach the internet through a local proxy client.
|
||||
|
||||
Open **Settings → Gateway → Proxy** and configure:
|
||||
|
||||
@@ -191,8 +232,12 @@ Notes:
|
||||
- A bare `host:port` value is treated as HTTP.
|
||||
- If advanced proxy fields are left empty, ClawX falls back to `Proxy Server`.
|
||||
- Saving proxy settings reapplies Electron networking immediately and restarts the Gateway automatically.
|
||||
- In cc-connect runtime mode, Codex child processes inherit the same `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, and bypass environment values.
|
||||
- ClawX also syncs the proxy to OpenClaw's Telegram channel config when Telegram is enabled.
|
||||
- In **Settings → Advanced → Developer**, you can run **OpenClaw Doctor** to execute `openclaw doctor --json` and inspect the diagnostic output without leaving the app.
|
||||
- Gateway restarts preserve an existing Telegram channel proxy if ClawX proxy is currently disabled.
|
||||
- To explicitly clear Telegram channel proxy from OpenClaw config, save proxy settings with proxy disabled.
|
||||
- In **Settings → Advanced → Developer**, Runtime Doctor runs `openclaw doctor --json` for OpenClaw. For cc-connect it combines bundled `cc-connect doctor user-isolation` with bundled `codex doctor --json` and stores a mode-0600 audit under the ClawX-managed runtime directory. Doctor Fix remains OpenClaw-only.
|
||||
- On packaged Windows builds, the bundled `openclaw` CLI/TUI runs via the shipped `node.exe` entrypoint to keep terminal input behavior stable.
|
||||
|
||||
---
|
||||
|
||||
@@ -200,58 +245,91 @@ Notes:
|
||||
|
||||
ClawX employs a **dual-process architecture** with a unified host API layer. The renderer talks to a single client abstraction, while Electron Main owns protocol selection and process lifecycle:
|
||||
|
||||
```┌─────────────────────────────────────────────────────────────────┐
|
||||
Chat transport follows the active runtime while preserving one renderer boundary. OpenClaw Chat uses an ACP stdio bridge owned by Electron Main; the renderer receives typed host events and renders an in-memory ACP timeline. cc-connect Chat is dispatched by `RuntimeManager` through cc-connect BridgePlatform, including session history, progress, approvals, and generated media. The renderer uses the same Host API facade in both modes and never invokes Codex directly. Non-Chat capabilities are also dispatched through runtime providers; OpenClaw-specific operations remain behind the OpenClaw adapter.
|
||||
|
||||
An unfinished ACP response keeps streaming when you open another conversation or page. Returning before it finishes restores the latest in-memory timeline and continues the live response; once it finishes, normal ACP history replay remains the source of truth.
|
||||
|
||||
ACP assistant turns show whole-turn duration. Live timing follows the client-observed prompt lifecycle and survives in-app navigation; historical timing is derived in Electron Main from bounded OpenClaw transcript timestamps and only annotates a turn already restored by ACP replay.
|
||||
|
||||
ACP Chat renders standard ACP resources as attachments. User-selected images appear as thumbnails with a filename hover overlay, while other available attachment cards show the filename and a muted, truncating source path. When the current OpenClaw ACP adapter omits assistant media, explicit assistant `MEDIA:` directives can also be recovered as attachment cards without displaying the raw directive. Existing local file references, including paths outside the active workspace, are revalidated in Electron Main for the exact session and generation before every preview or open. Previewable local attachments produced by the AI, including `.docx` and `.pptx` files within the 20 MB inline-preview limit, keep their primary read-only in-app preview action and provide a secondary menu for opening with compatible applications or revealing the file in Finder, File Explorer, or the system file manager. For local HTML attachments, that menu starts with an action that opens the file URL in the right-side Web Browser. The same Office limitations apply here: `.doc` and `.ppt` remain system-open formats, DOCX pagination may differ from Microsoft Word, and PPTX animations, transitions, and media playback are unsupported. Compatible-application discovery is available only on macOS and Windows and silently degrades to reveal-only behavior on Linux or when discovery fails. Other local files, including Office files larger than 20 MB, open in the system application after a user click; remote HTTP and HTTPS attachments open externally after a user click. Bare or inline prose paths are not treated as attachments.
|
||||
|
||||
ACP Chat can also display generated image previews when image-generation media is delivered by the runtime as trusted structured media. Trusted OpenClaw internal-UI deliveries and task-correlated final replies preserve the original user-facing completion text, including text-only failure explanations, rather than replacing it with a generic image caption. During historical OpenClaw replay, assistant image `MEDIA:` markers are promoted to the inline image experience only when they follow a recorded image-generation task start for that session. ClawX loads previews through host media handling in Electron Main, not arbitrary Renderer filesystem access. Standard ACP image and resource content remains the preferred path and renders directly.
|
||||
|
||||
### ACP File Activity Semantics
|
||||
|
||||
- File activity is projected from successful, completed OpenClaw `write`, `edit`, and `apply_patch` calls. Tool recognition follows the official OpenClaw Chat UI; filtering to completed calls is specific to ClawX.
|
||||
- Created and modified activity rows use the same file-card shell and **Open with** menu as previewable assistant attachments while retaining their status and optional `+/-` summary. For HTML files, the first menu item opens the local file URL in the right-side Web Browser and activates that tab. Deleted rows keep only the **Changes** action. Every application-list, selected-application, and reveal request is independently revalidated in Electron Main from the workspace root and relative path; tool-derived paths never become attachments or expose canonical native paths to Renderer.
|
||||
- A `write` is shown as the tool declares it: a creation with an all-added diff, even if the path may already exist.
|
||||
- **Changes** is a chronological, session-level record of tool-declared activity. It is not Git output or a verified diff against a source baseline.
|
||||
- For each file, Changes renders at most one diff editor per assistant turn. Sequential fragments are composed when safe; independent fragments share one concatenated editor without claiming a complete-file baseline.
|
||||
- Side effects made by shell commands, scripts, users, or IDEs are not detected.
|
||||
- A full ACP replay can restore recorded file activity. If replay is incomplete, ClawX does not infer missing activity through fallback behavior.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ ClawX Desktop App │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Electron Main Process │ │
|
||||
│ │ Electron Main Process │ │
|
||||
│ │ • Window & application lifecycle management │ │
|
||||
│ │ • Gateway process supervision │ │
|
||||
│ │ • System integration (tray, notifications, keychain) │ │
|
||||
│ │ • Auto-update orchestration │ │
|
||||
│ │ • Gateway process supervision │ │
|
||||
│ │ • System integration (tray, notifications, keychain) │ │
|
||||
│ │ • Auto-update orchestration │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ │ IPC (authoritative control plane) │
|
||||
│ ▼ │
|
||||
│ │ │
|
||||
│ │ IPC (authoritative control plane) │
|
||||
│ ▼ │
|
||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ │ React Renderer Process │ │
|
||||
│ │ • Modern component-based UI (React 19) │ │
|
||||
│ │ • State management with Zustand │ │
|
||||
│ │ • Unified host-api/api-client calls │ │
|
||||
│ │ • Rich Markdown rendering │ │
|
||||
│ │ React Renderer Process │ │
|
||||
│ │ • Modern component-based UI (React 19) │ │
|
||||
│ │ • State management with Zustand │ │
|
||||
│ │ • Unified host-api/api-client calls │ │
|
||||
│ │ • Rich Markdown rendering │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────┬──────────────────────────────────┘
|
||||
└──────────────────────────────┬───────────────────────────────────┘
|
||||
│
|
||||
│ Main-owned transport strategy
|
||||
│ (WS first, HTTP then IPC fallback)
|
||||
│ Typed IPC requests
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Host API & Main Process Proxies │
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ Main Host Services & Runtime Manager │
|
||||
│ │
|
||||
│ • hostapi:fetch (Main proxy, avoids CORS in dev/prod) │
|
||||
│ • gateway:httpProxy (Renderer never calls Gateway HTTP direct) │
|
||||
│ • Unified error mapping & retry/backoff │
|
||||
└──────────────────────────────┬──────────────────────────────────┘
|
||||
│ • host:invoke typed service dispatcher │
|
||||
│ • Settings, files, sessions, skills, providers, diagnostics │
|
||||
│ • Runtime selection, transport, and process supervision │
|
||||
└──────────────────────────────┬───────────────────────────────────┘
|
||||
│
|
||||
│ WS / HTTP / IPC fallback
|
||||
│ Main-owned WebSocket
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ OpenClaw Gateway │
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ OpenClaw Gateway path (shown) │
|
||||
│ │
|
||||
│ • AI agent runtime and orchestration │
|
||||
│ • AI agent runtime and orchestration │
|
||||
│ • Message channel management │
|
||||
│ • Skill/plugin execution environment │
|
||||
│ • Skill/plugin execution environment │
|
||||
│ • Provider abstraction layer │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
### Design Principles
|
||||
|
||||
- **Process Isolation**: The AI runtime operates in a separate process, ensuring UI responsiveness even during heavy computation
|
||||
- **Single Entry for Frontend Calls**: Renderer requests go through host-api/api-client; protocol details are hidden behind a stable interface
|
||||
- **Main-Process Transport Ownership**: Electron Main controls WS/HTTP usage and fallback to IPC for reliability
|
||||
- **Main-Process Transport Ownership**: Electron Main owns OpenClaw ACP/Gateway transports and cc-connect BridgePlatform dispatch; the renderer talks to Main over typed IPC
|
||||
- **Extension IPC Contributions**: Main-process extensions contribute host-api actions through the typed IPC registry instead of HTTP routes
|
||||
- **Graceful Recovery**: Built-in reconnect, timeout, and backoff logic handles transient failures automatically
|
||||
- **Secure Storage**: API keys and sensitive data leverage the operating system's native secure storage mechanisms
|
||||
- **CORS-Safe by Design**: Local HTTP access is proxied by Main, preventing renderer-side CORS issues
|
||||
- **CORS-Safe by Design**: The renderer does not call local Gateway or Host API HTTP endpoints directly
|
||||
|
||||
### Process Model & Gateway Troubleshooting
|
||||
|
||||
- ClawX is an Electron app, so **one app instance normally appears as multiple OS processes** (main/renderer/zygote/utility). This is expected.
|
||||
- Single-instance protection uses Electron's lock plus a cross-install writer lock under `~/.clawx/locks`. ClawX acquires that file lock before shared data initialization, migration, runtime, or scheduler startup and refuses to start if ownership cannot be established.
|
||||
- During rolling upgrades, mixed old/new app versions can still have asymmetric protection behavior. For best reliability, upgrade all desktop clients to the same version.
|
||||
- The OpenClaw Gateway listener should still be **single-owner**: only one process should listen on `127.0.0.1:18789`.
|
||||
- Gateway readiness is based on OpenClaw core signals such as `system-presence`, `health`, and `status`; memory, Dreams, or channel failures are shown as capability degradation instead of global Gateway failure.
|
||||
- To verify the active listener:
|
||||
- macOS/Linux: `lsof -nP -iTCP:18789 -sTCP:LISTEN`
|
||||
- Windows (PowerShell): `Get-NetTCPConnection -LocalPort 18789 -State Listen`
|
||||
- Clicking the window close button (`X`) hides ClawX to tray; it does **not** fully quit the app. Use tray menu **Quit ClawX** for complete shutdown.
|
||||
|
||||
---
|
||||
|
||||
@@ -275,16 +353,19 @@ Chain multiple skills together to create sophisticated automation pipelines. Pro
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Node.js**: 22+ (LTS recommended)
|
||||
- **Node.js**: 22.22.3+, 24.15.0+, or 25.9.0+ within the corresponding supported major line (Node 24 LTS recommended)
|
||||
- **Package Manager**: pnpm 9+ (recommended) or npm
|
||||
- **Linux (Ubuntu/Debian)**: Install required system libraries before running Electron:
|
||||
```bash
|
||||
sudo apt-get install -y libnss3 libgtk-3-0 libxss1 libxtst6 libatspi2.0-0 libnotify4 xdg-utils
|
||||
```
|
||||
On Ubuntu 24.04+, some packages use a `t64` suffix; run the above command and `apt` will automatically select the correct variant.
|
||||
|
||||
### Project Structure
|
||||
|
||||
```ClawX/
|
||||
├── electron/ # Electron Main Process
|
||||
│ ├── api/ # Main-side API router and handlers
|
||||
│ │ └── routes/ # RPC/HTTP proxy route modules
|
||||
│ ├── services/ # Provider, secrets and runtime services
|
||||
│ ├── services/ # Typed host APIs, provider, secrets and runtime services
|
||||
│ │ ├── providers/ # Provider/account model sync logic
|
||||
│ │ └── secrets/ # OS keychain and secret storage
|
||||
│ ├── shared/ # Shared provider schemas/constants
|
||||
@@ -301,16 +382,19 @@ Chain multiple skills together to create sophisticated automation pipelines. Pro
|
||||
│ ├── i18n/ # Localization resources
|
||||
│ └── types/ # TypeScript type definitions
|
||||
├── tests/
|
||||
│ ├── e2e/ # Playwright Electron end-to-end smoke tests
|
||||
│ └── unit/ # Vitest unit/integration-like tests
|
||||
├── resources/ # Static assets (icons/images)
|
||||
└── scripts/ # Build and utility scripts
|
||||
```
|
||||
### Available Commands
|
||||
|
||||
Real cc-connect verification can load local env files, but repo-local credential files must be gitignored; external `--env-file` paths are allowed without being written to reports. Use `.env.cc-connect.local.example` as the field template for `.env.cc-connect.local`.
|
||||
|
||||
```bash
|
||||
# Development
|
||||
pnpm run init # Install dependencies + download uv
|
||||
pnpm dev # Start with hot reload
|
||||
pnpm run init # Install dependencies + download bundled binaries (uv, agent-browser)
|
||||
pnpm dev # Start with hot reload (auto-prepares bundled skills if missing)
|
||||
|
||||
# Quality
|
||||
pnpm lint # Run ESLint
|
||||
@@ -318,15 +402,87 @@ pnpm typecheck # TypeScript validation
|
||||
|
||||
# Testing
|
||||
pnpm test # Run unit tests
|
||||
pnpm run test:e2e # Run Electron E2E smoke tests with Playwright
|
||||
pnpm run test:e2e:cc-connect:codex-oauth-lifecycle # Verify cc-connect Codex OAuth Host API status/import/logout without real credentials
|
||||
CLAWX_REAL_OAUTH_E2E=1 CLAWX_REAL_CODEX_AUTH_JSON="$HOME/.codex/auth.json" pnpm run test:e2e:cc-connect:real-oauth # Verify real OAuth tool execution and the Chat execution graph
|
||||
pnpm run test:e2e:headed # Run Electron E2E tests with a visible window
|
||||
pnpm run comms:replay # Compute communication replay metrics
|
||||
pnpm run comms:baseline # Refresh communication baseline snapshot
|
||||
pnpm run comms:compare # Compare replay metrics against baseline thresholds
|
||||
pnpm run verify:cc-connect:local-real # Write a local cc-connect real-validation preflight report
|
||||
pnpm run verify:cc-connect:local-real:run # Run safe local cc-connect real-validation checks and write the report
|
||||
pnpm run verify:cc-connect:local-real:oauth # Also run dev cc-connect real OAuth comprehensive smoke when CLAWX_REAL_CODEX_AUTH_JSON has a complete refresh-token set
|
||||
pnpm run verify:cc-connect:local-real:oauth-all # Also run dev and packaged cc-connect real OAuth smokes when CLAWX_REAL_CODEX_AUTH_JSON has a complete refresh-token set
|
||||
pnpm run verify:cc-connect:local-real:api-key # Run local OpenAI-compatible API-key chat/abort smokes; also run real OpenAI API-key smoke when credentials are available
|
||||
pnpm run verify:cc-connect:local-real:feishu # Also run real Feishu/Lark lifecycle smoke when credentials and CLAWX_REAL_CODEX_AUTH_JSON are available
|
||||
pnpm run verify:cc-connect:local-real:feishu-inbound # Also run the manual real Feishu/Lark inbound marker smoke when the sandbox tenant fixture is enabled
|
||||
pnpm run verify:cc-connect:local-real:scheduled-cron # Also run real native exec cron; with Codex auth, verify native prompt scheduling through public cc-connect session history
|
||||
pnpm run verify:cc-connect:local-real:all # Run every available local real cc-connect validation path and write the external gate handoff
|
||||
pnpm run verify:cc-connect:local-real:all-strict # Require all real credentials and runtime parity coverage for release-candidate validation; writes the handoff before failing
|
||||
pnpm run verify:cc-connect:local-real:replacement-ready # Require replacement readiness without making missing credentials a separate preflight failure; writes the handoff before failing
|
||||
pnpm run verify:cc-connect:local-real:replacement-ready:check # Same readiness gate without overwriting the last report artifacts
|
||||
pnpm run verify:cc-connect:local-real:packaged-oauth # Also run packaged cc-connect real OAuth smoke when CLAWX_REAL_CODEX_AUTH_JSON has a complete refresh-token set
|
||||
pnpm run verify:cc-connect:local-real:external-gates:check # Check remaining required external gates without overwriting report artifacts
|
||||
pnpm run verify:cc-connect:local-real:external-gates # Run only the remaining required external gates and fail unless all three pass
|
||||
pnpm run verify:cc-connect:local-real:handoff # Generate a credential-free handoff checklist for remaining external gates
|
||||
|
||||
# The report is written to artifacts/cc-connect/local-real-validation-report.{json,md};
|
||||
# The external gate handoff is written to artifacts/cc-connect/local-real-external-gates.{md,json} by :all, :all-strict, :replacement-ready, :external-gates, or :handoff.
|
||||
# The JSON handoff is machine-readable and contains only sanitized status, env-var names, commands, and safety notes.
|
||||
# runtimeMatrixStatus shows pass/partial/fail coverage separately from hard-gate exit status.
|
||||
# Use --no-write, replacement-ready:check, or external-gates:check for non-destructive gate checks; missing preconditions and next commands are printed without secret values.
|
||||
# validationGaps records required local gate gaps separately from follow-up full-parity evidence gaps.
|
||||
# partial reports include Next Actions with follow-up commands and no secret values.
|
||||
# Real credentials can be supplied through untracked/gitignored .env.cc-connect.local,
|
||||
# --env-file=<path>, or CLAWX_REAL_ENV_FILE / CLAWX_REAL_ENV_FILES; process env values win.
|
||||
# API-key smoke can set CLAWX_REAL_OPENAI_MODEL when the default model is not available.
|
||||
|
||||
# Build & Package
|
||||
pnpm run build:vite # Build frontend only
|
||||
pnpm build # Full production build (with packaging assets)
|
||||
pnpm package # Package for current platform
|
||||
pnpm package # Package for current platform (includes bundled preinstalled skills)
|
||||
pnpm package:mac # Package for macOS
|
||||
pnpm package:win # Package for Windows
|
||||
pnpm package:linux # Package for Linux
|
||||
pnpm run verify:runtime-bundles # Verify downloaded cc-connect/Codex bundle manifests and binaries
|
||||
pnpm run verify:packaged-runtime-resources -- --resources=<path> --platform=<darwin|win32|linux> --arch=<x64|arm64> # Verify final Electron runtime resources
|
||||
pnpm run smoke:cc-connect:packaged # Launch the native unpacked app and verify cc-connect start/status/Cron/Doctor/rollback/cleanup
|
||||
```
|
||||
|
||||
On headless Linux, run Electron tests under a display server such as `xvfb-run -a pnpm run test:e2e`.
|
||||
|
||||
### Communication Regression Checks
|
||||
|
||||
When a PR changes communication paths (gateway events, ACP Chat bridge send/receive flow, channel delivery, or transport fallback), run:
|
||||
|
||||
```bash
|
||||
pnpm run comms:replay
|
||||
pnpm run comms:compare
|
||||
```
|
||||
|
||||
`comms-regression` in CI enforces required scenarios and threshold checks.
|
||||
|
||||
### Electron E2E Tests
|
||||
|
||||
The Playwright Electron suite launches the packaged renderer and main process
|
||||
from `dist/` and `dist-electron/`, so it does not require manually running
|
||||
`pnpm dev` first.
|
||||
|
||||
`pnpm run test:e2e` automatically:
|
||||
|
||||
- builds the renderer and Electron bundles with `pnpm run build:vite`
|
||||
- starts Electron in an isolated E2E mode with a temporary `HOME`
|
||||
- uses a temporary ClawX `userData` directory
|
||||
- skips heavy startup side effects such as gateway auto-start, bundled skill
|
||||
installation, tray creation, and CLI auto-install
|
||||
|
||||
The first two baseline specs cover:
|
||||
|
||||
- first-launch setup wizard visibility on a fresh profile
|
||||
- skipping setup and navigating to the Models page inside the Electron app
|
||||
|
||||
Add future Electron flows under `tests/e2e/` and reuse the shared fixture in
|
||||
`tests/e2e/fixtures/electron.ts`.
|
||||
### Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|
||||
+485
@@ -0,0 +1,485 @@
|
||||
|
||||
<p align="center">
|
||||
<img src="src/assets/logo.svg" width="128" height="128" alt="ClawX Logo" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">ClawX</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>Десктоп-интерфейс для AI-агентов OpenClaw</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#возможности">Возможности</a> •
|
||||
<a href="#почему-clawx">Почему ClawX</a> •
|
||||
<a href="#быстрый-старт">Быстрый старт</a> •
|
||||
<a href="#архитектура">Архитектура</a> •
|
||||
<a href="#разработка">Разработка</a> •
|
||||
<a href="#участие">Участие</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/platform-MacOS%20%7C%20Windows%20%7C%20Linux-blue" alt="Platform" />
|
||||
<img src="https://img.shields.io/badge/electron-40+-47848F?logo=electron" alt="Electron" />
|
||||
<img src="https://img.shields.io/badge/react-19-61DAFB?logo=react" alt="React" />
|
||||
<a href="https://discord.com/invite/84Kex3GGAh" target="_blank">
|
||||
<img src="https://img.shields.io/discord/1399603591471435907?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb" alt="chat on Discord" />
|
||||
</a>
|
||||
<img src="https://img.shields.io/github/downloads/ValueCell-ai/ClawX/total?color=%23027DEB" alt="Downloads" />
|
||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | <a href="README.ja-JP.md">日本語</a> | Русский
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Обзор
|
||||
|
||||
**ClawX** — это мост между мощными AI-агентами и повседневными пользователями. Построенный на базе [OpenClaw](https://github.com/OpenClaw), он превращает управление AI через командную строку в доступный и красивый десктоп-опыт — терминал не нужен.
|
||||
|
||||
Автоматизация рабочих процессов, управление AI-каналами или планирование интеллектуальных задач — ClawX предоставляет интерфейс для эффективного использования AI-агентов.
|
||||
|
||||
ClawX поставляется с предустановленными лучшими практиками для провайдеров моделей и нативно поддерживает Windows, а также многоязычные настройки. Вы можете тонко настроить расширенные параметры через **Настройки → Дополнительно → Режим разработчика**.
|
||||
|
||||
<p align="center"><strong style="font-size:1.1em; text-decoration: underline;">Для получения полной корпоративной версии, специализированной поддержки или индивидуального сопровождения внедрения под ваш бизнес-сценарий, свяжитесь с нами по адресу <a href="mailto:public@valuecell.ai">public@valuecell.ai</a>.</strong></p>
|
||||
|
||||
---
|
||||
|
||||
## Скриншоты
|
||||
|
||||
<p align="center">
|
||||
<img src="resources/screenshot/ru/chat.png" style="width: 100%; height: auto;">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="resources/screenshot/ru/cron.png" style="width: 100%; height: auto;">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="resources/screenshot/ru/skills.png" style="width: 100%; height: auto;">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="resources/screenshot/ru/channels.png" style="width: 100%; height: auto;">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="resources/screenshot/ru/models.png" style="width: 100%; height: auto;">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="resources/screenshot/ru/settings.png" style="width: 100%; height: auto;">
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Почему ClawX
|
||||
|
||||
Создание AI-агентов не должно требовать владения командной строкой. Философия ClawX проста: **мощные технологии заслуживают интерфейса, который уважает ваше время.**
|
||||
|
||||
| Проблема | Решение ClawX |
|
||||
|----------|---------------|
|
||||
| Сложная настройка через CLI | Установка в один клик с мастером настройки |
|
||||
| Редактирование конфигурационных файлов | Визуальные настройки с проверкой в реальном времени |
|
||||
| Управление процессами | Автоматическое управление жизненным циклом шлюза |
|
||||
| Несколько AI-провайдеров | Единая панель настройки провайдеров |
|
||||
| Установка навыков/плагинов | Встроенный маркетплейс и управление навыками |
|
||||
|
||||
### OpenClaw внутри
|
||||
|
||||
ClawX построен непосредственно на официальном ядре **OpenClaw**. Вместо отдельной установки мы встраиваем среду выполнения в приложение для бесшовного опыта "всё включено".
|
||||
|
||||
Мы стремимся поддерживать строгое соответствие с проектом OpenClaw, чтобы вы всегда имели доступ к новейшим возможностям, улучшениям стабильности и совместимости с экосистемой.
|
||||
|
||||
---
|
||||
|
||||
## Возможности
|
||||
|
||||
### 🎯 Нулевой порог настройки
|
||||
Весь процесс — от установки до первого взаимодействия с AI — выполняется через интуитивный графический интерфейс. Без терминальных команд, без YAML-файлов, без поиска переменных окружения.
|
||||
|
||||
### 💬 Интеллектуальный интерфейс чата
|
||||
Общайтесь с AI-агентами через современный чат. Поддержка нескольких контекстов разговора, истории сообщений, рендеринга Markdown (включая таблицы GitHub-flavored и математические формулы LaTeX через KaTeX: `$строчные$`, `$$блочные$$`, `\(строчные\)` и `\[блочные\]`) и прямая маршрутизация через `@agent` в главном поле ввода для мультиагентных конфигураций.
|
||||
Навыки, вставляемые из поля ввода, отображаются как чипы `/skill-name`; нажмите на чип, чтобы открыть боковую панель предпросмотра и прочитать `SKILL.md` соответствующего навыка.
|
||||
При выборе другого агента через `@agent` ClawX переключается непосредственно в контекст этого агента вместо ретрансляции через агента по умолчанию. Рабочие пространства агентов по умолчанию разделены, но более строгая изоляция зависит от настроек песочницы OpenClaw.
|
||||
Каждый агент может переопределить свои настройки `provider/model`; агенты без переопределения продолжают наследовать глобальную модель по умолчанию.
|
||||
|
||||
### Одностраничный веб-браузер
|
||||
На правой панели Chat находятся четыре вкладки: «Рабочая область», «Просмотр», «Изменения» и «Веб-браузер». При первом использовании веб-браузер лениво создаёт одну активную страницу и не останавливает её при закрытии панели, выборе другой вкладки панели, переключении сессии чата или переходе на другой маршрут ClawX; скрытая страница может продолжать выполнять скрипты, обращаться к сети, воспроизводить звук и расходовать ресурсы. Выделенная постоянная сессия сохраняет cookie и хранилища сайтов после перезапуска приложения, но каждый новый запуск начинается с `about:blank` без восстановления предыдущего URL, состояния страницы или истории переходов. Если страница предоставляет favicon, он отображается рядом с заголовком; пока favicon недоступен, заполнитель того же размера сохраняет положение заголовка, а при редактировании адреса вся область значка скрывается. Дополнительных вкладок или окон браузера, закладок, сохраняемой истории, менеджера паролей и управления автозаполнением нет.
|
||||
|
||||
Навигация верхнего уровня принимает HTTP, HTTPS и явно введённые стандартные URL `file:///`. Обычные пути файловой системы и другие протоколы отклоняются. Открытие локального файла предоставляет встроенной странице доступ к его читаемому содержимому в рамках обычных правил безопасности Chromium; команда **Открыть в системном браузере** для URL `file:` может запустить связанное с файлом приложение ОС, а не браузер. Разрешённая цель всплывающего окна заменяет текущую страницу, а не создаёт дочернее окно. Такой переход в той же странице не сохраняет `window.opener`, возвращаемые дескрипторы окон, сценарии с первоначально пустым окном, а также полную семантику тела POST, referrer, именованных окон и параметров окна.
|
||||
|
||||
Для загрузок сохраняется стандартное поведение Electron и операционной системы. В зависимости от платформы может появиться нативный диалог сохранения, требующий действий пользователя; ClawX не задаёт собственный путь и не предоставляет интерфейс прогресса, истории или управления загрузками. Для каждого запроса камеры или микрофона показывается нативный диалог разрешения или запрета, а решение не запоминается. Доступ к буферу обмена разрешён; геолокация, захват экрана, уведомления и остальные разрешения отклоняются.
|
||||
|
||||
**Очистить файлы cookie** удаляет только cookie всех источников в сессии браузера, сохраняя кэш и хранилища сайтов. **Очистить данные сайта** удаляет HTTP/Chromium-кэш, Cache Storage, Local Storage, IndexedDB и Service Workers всех источников, сохраняя cookie и загруженные файлы. Трафик браузера использует системное разрешение прокси Electron/Chromium; настройки клиентского прокси ClawX не синхронизируются с этой сессией браузера, и их изменение не перенастраивает её.
|
||||
|
||||
### 📡 Управление несколькими каналами
|
||||
Настраивайте и отслеживайте несколько AI-каналов одновременно. Каждый канал работает независимо, позволяя запускать специализированных агентов для разных задач.
|
||||
Каждый канал теперь поддерживает несколько учётных записей, привязку агента к учётной записи и переключение канала по умолчанию прямо на странице Каналы.
|
||||
Для пользовательских идентификаторов учётных записей каналов ClawX требует совместимый с OpenClaw канонический формат (`[a-z0-9_-]`, строчные буквы, максимум 64 символа, должен начинаться с буквы или цифры) для предотвращения ошибок маршрутизации.
|
||||
ClawX также включает официальный плагин личного WeChat от Tencent, позволяя подключить WeChat напрямую со страницы Каналы через встроенный QR-код.
|
||||
|
||||
### ⏰ Автоматизация по расписанию
|
||||
Планируйте автоматический запуск AI-задач. Определяйте триггеры, устанавливайте интервалы и позволяйте AI-агентам работать круглосуточно без ручного вмешательства.
|
||||
На странице Cron теперь можно настроить внешнюю доставку непосредственно в форме задачи с отдельными селекторами учётной записи отправителя и цели получателя. Для поддерживаемых каналов цели получателей автоматически обнаруживаются из каталогов каналов или известной истории сессий, поэтому больше не нужно редактировать `jobs.json` вручную. Поле сообщения задачи также поддерживает вставку навыков с помощью того же синтаксиса встроенных токенов `/skill`, что и в основном окне чата (с учётом выбранного агента), поэтому запланированные подсказки могут запускать навыки напрямую. Выбор расписания разделён на вкладки **Повтор** и **Однократно**: повтор предлагает частоты «Ежечасно», «Ежедневно», «По будням», «Еженедельно» и «Свой» (произвольный cron) со встроенными элементами выбора времени/дня недели, а однократно запускает задачу один раз в выбранную дату (с показом дня недели) и время. Однократные задачи должны быть запланированы на будущее и автоматически удаляются средой выполнения после завершения.
|
||||
|
||||
### 🧩 Расширяемая система навыков
|
||||
Расширяйте возможности AI-агентов готовыми навыками. Просматривайте, устанавливайте и управляйте навыками через встроенную панель — менеджеры пакетов не нужны.
|
||||
ClawX также предварительно упаковывает полные навыки обработки документов (`pdf`, `xlsx`, `docx`, `pptx`), автоматически развёртывает их в управляемый каталог навыков (по умолчанию `~/.openclaw/skills`) при запуске и включает по умолчанию при первой установке.
|
||||
На странице Навыки отображаются навыки из нескольких источников OpenClaw (управляемый каталог, workspace и дополнительные каталоги навыков), а также показывается фактическое расположение каждого навыка для прямого открытия папки.
|
||||
|
||||
### 🔐 Безопасная интеграция провайдеров
|
||||
Подключайтесь к нескольким AI-провайдерам (OpenAI, Anthropic, Z.AI / GLM и др.) с учётными данными, безопасно хранящимися в системной связке ключей. OpenAI поддерживает как API-ключи, так и OAuth через браузер (подписка Codex).
|
||||
Для провайдеров **Custom**, используемых с OpenAI-совместимыми шлюзами, вы можете установить пользовательский `User-Agent` в **Настройки → AI Провайдеры → Редактировать провайдера** для совместимости с чувствительными эндпоинтами.
|
||||
Z.AI (CN / Global) соответствует встроенному провайдеру OpenClaw `zai` (`ZAI_API_KEY`). Модель по умолчанию — `glm-5.2`. Пресет Code Plan переключает на эндпоинты Coding Plan (`…/api/coding/paas/v4`); также доступны обычные API (`…/api/paas/v4`). CN и Global взаимоисключающие, так как используют один и тот же runtime-ключ OpenClaw.
|
||||
Когда совместимый шлюз отклоняет `/models` по причинам, не связанным с аутентификацией, ClawX автоматически переключается на легковесный зонд `/chat/completions` или `/responses` при проверке API-ключа.
|
||||
|
||||
### 🌙 Адаптивные темы
|
||||
Светлая тема, тёмная тема или синхронизация с системой. ClawX автоматически адаптируется к вашим предпочтениям.
|
||||
|
||||
### 🚀 Управление автозапуском
|
||||
В **Настройки → Общие** вы можете включить **Запускать при старте системы**, чтобы ClawX автоматически запускался после входа в систему.
|
||||
|
||||
---
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
### Системные требования
|
||||
|
||||
- **Операционная система**: macOS 11+, Windows 10+ или Linux (Ubuntu 20.04+)
|
||||
- **Память**: минимум 4 ГБ RAM (рекомендуется 8 ГБ)
|
||||
- **Хранилище**: 1 ГБ свободного места на диске
|
||||
|
||||
### Установка
|
||||
|
||||
#### Готовые релизы (рекомендуется)
|
||||
|
||||
Скачайте последний релиз для вашей платформы со страницы [Releases](https://github.com/ValueCell-ai/ClawX/releases).
|
||||
|
||||
#### Сборка из исходников
|
||||
|
||||
```bash
|
||||
# Клонирование репозитория
|
||||
git clone https://github.com/ValueCell-ai/ClawX.git
|
||||
cd ClawX
|
||||
|
||||
# Инициализация проекта
|
||||
pnpm run init
|
||||
|
||||
# Запуск в режиме разработки
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### Первый запуск
|
||||
|
||||
При первом запуске ClawX **Мастер настройки** проведёт вас через:
|
||||
|
||||
1. **Язык и регион** — настройка предпочтительного языка и региона
|
||||
2. **AI-провайдер** — добавление провайдеров с API-ключами или OAuth (для провайдеров, поддерживающих вход через браузер/устройство)
|
||||
3. **Пакеты навыков** — выбор предустановленных навыков для распространённых сценариев
|
||||
4. **Проверка** — тестирование конфигурации перед входом в основной интерфейс
|
||||
|
||||
Мастер предварительно выбирает системный язык, если он поддерживается, иначе переключается на английский.
|
||||
|
||||
### Настройки прокси
|
||||
|
||||
ClawX включает встроенные настройки прокси для сред, где Electron, шлюз OpenClaw или каналы вроде Telegram должны выходить в интернет через локальный прокси-клиент.
|
||||
|
||||
Откройте **Настройки → Шлюз → Прокси** и настройте:
|
||||
|
||||
- **Прокси-сервер**: прокси по умолчанию для всех запросов
|
||||
- **Правила обхода**: хосты, которые должны подключаться напрямую, разделённые точкой с запятой, запятыми или новыми строками
|
||||
- В **Режиме разработчика** можно дополнительно переопределить:
|
||||
- **HTTP Прокси**
|
||||
- **HTTPS Прокси**
|
||||
- **ALL_PROXY / SOCKS**
|
||||
|
||||
Рекомендуемые примеры локальных настроек:
|
||||
|
||||
```text
|
||||
Прокси-сервер: http://127.0.0.1:7890
|
||||
```
|
||||
Примечания:
|
||||
|
||||
- Значение `host:port` рассматривается как HTTP.
|
||||
- Если расширенные поля прокси пусты, ClawX использует `Прокси-сервер`.
|
||||
- Сохранение настроек прокси немедленно повторно применяет сеть Electron и автоматически перезапускает шлюз.
|
||||
- ClawX также синхронизирует прокси с конфигурацией канала Telegram в OpenClaw, когда Telegram включён.
|
||||
- При перезапуске шлюза существующий прокси канала Telegram сохраняется, если прокси ClawX отключен.
|
||||
- Чтобы явно очистить прокси Telegram из конфигурации OpenClaw, сохраните настройки прокси с отключенным прокси.
|
||||
- В **Настройки → Дополнительно → Разработчик** можно запустить **OpenClaw Doctor** для выполнения `openclaw doctor --json` и просмотра диагностического вывода, не покидая приложение.
|
||||
- В упакованных сборках Windows встроенный `openclaw` CLI/TUI запускается через поставляемый `node.exe` для стабильного поведения ввода в терминале.
|
||||
|
||||
---
|
||||
|
||||
## Архитектура
|
||||
|
||||
ClawX использует **двухпроцессную архитектуру с унифицированным уровнем Host API**. Рендерер обращается к единой абстракции клиента, а Electron Main управляет выбором протокола и жизненным циклом процессов:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Десктоп-приложение ClawX │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Главный процесс Electron │ │
|
||||
│ │ • Управление жизненным циклом окна и приложения │ │
|
||||
│ │ • Наблюдение за процессом шлюза │ │
|
||||
│ │ • Интеграция с системой (трей, уведомления, связка ключей)│ │
|
||||
│ │ • Оркестрация автообновлений │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ IPC (авторитетная плоскость управления) │
|
||||
│ ▼ │
|
||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Процесс рендерера React │ │
|
||||
│ │ • Современный UI на компонентах (React 19) │ │
|
||||
│ │ • Управление состоянием с Zustand │ │
|
||||
│ │ • Унифицированные вызовы host-api/api-client │ │
|
||||
│ │ • Рендеринг Markdown │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────┬──────────────────────────────────┘
|
||||
│
|
||||
Стратегия транспорта, управляемая Main
|
||||
(Сначала WS, затем HTTP, затем IPC)
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ Host API и прокси-уровень Main │
|
||||
│ │
|
||||
│ • hostapi:fetch (прокси Main, избегает CORS в dev/prod) │
|
||||
│ • gateway:httpProxy (Рендерер не вызывает Gateway HTTP напрямую)│
|
||||
│ • Унифицированное отображение ошибок и повторные попытки │
|
||||
└──────────────────────────────┬───────────────────────────────────┘
|
||||
│
|
||||
Резерв WS / HTTP / IPC
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Шлюз OpenClaw │
|
||||
│ │
|
||||
│ • Среда выполнения AI-агентов и оркестрация │
|
||||
│ • Управление каналами сообщений │
|
||||
│ • Среда выполнения навыков/плагинов │
|
||||
│ • Уровень абстракции провайдеров │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Принципы проектирования
|
||||
|
||||
- **Изоляция процессов**: Среда выполнения AI работает в отдельном процессе, обеспечивая отзывчивость UI даже при тяжёлых вычислениях
|
||||
- **Единая точка входа для фронтенда**: Запросы рендерера проходят через host-api/api-client; детали протокола скрыты за стабильным интерфейсом
|
||||
- **Владение транспортом в Main**: Electron Main управляет использованием WS/HTTP и откатом к IPC для надёжности
|
||||
- **Корректное восстановление**: Встроенная логика переподключения, таймаутов и отката автоматически обрабатывает временные сбои
|
||||
- **Безопасное хранение**: API-ключи и конфиденциальные данные используют нативные механизмы безопасного хранения ОС
|
||||
- **Безопасность CORS**: Локальный HTTP-доступ проксируется через Main, предотвращая CORS-проблемы на стороне рендерера
|
||||
|
||||
### Модель процессов и устранение неполадок шлюза
|
||||
|
||||
- ClawX — это приложение Electron, поэтому **один экземпляр приложения обычно отображается как несколько процессов ОС** (main/renderer/zygote/utility). Это нормально.
|
||||
- Защита единственного экземпляра использует блокировку Electron плюс локальный файл блокировки процессов, предотвращая дублирование запуска приложения в средах с нестабильным IPC/сессионной шиной.
|
||||
- При последовательных обновлениях смешанные старые/новые версии могут иметь асимметричное поведение защиты. Для лучшей надёжности обновите все десктоп-клиенты до одной версии.
|
||||
- Слушатель шлюза OpenClaw должен быть **единственным владельцем**: только один процесс должен слушать `127.0.0.1:18789`.
|
||||
- Для проверки активного слушателя:
|
||||
- macOS/Linux: `lsof -nP -iTCP:18789 -sTCP:LISTEN`
|
||||
- Windows (PowerShell): `Get-NetTCPConnection -LocalPort 18789 -State Listen`
|
||||
- Нажатие кнопки закрытия окна (`X`) скрывает ClawX в трей; это **не** полностью закрывает приложение. Используйте меню трея **Quit ClawX** для полного завершения.
|
||||
|
||||
---
|
||||
|
||||
## Варианты использования
|
||||
|
||||
### 🤖 Персональный AI-ассистент
|
||||
Настройте универсального AI-агента, который может отвечать на вопросы, составлять письма, резюмировать документы и помогать с повседневными задачами — всё через чистый десктоп-интерфейс.
|
||||
|
||||
### 📊 Автоматизированный мониторинг
|
||||
Настройте запланированных агентов для отслеживания новостных лент, цен или определённых событий. Результаты доставляются в ваш предпочтительный канал уведомлений.
|
||||
|
||||
### 💻 Производительность разработчика
|
||||
Интегрируйте AI в рабочий процесс разработки. Используйте агентов для проверки кода, генерации документации или автоматизации повторяющихся задач кодирования.
|
||||
|
||||
### 🔄 Автоматизация рабочих процессов
|
||||
Связывайте несколько навыков для создания сложных конвейеров автоматизации. Обрабатывайте данные, преобразовывайте контент и запускайте действия — всё визуально оркестрируется.
|
||||
|
||||
---
|
||||
|
||||
## Разработка
|
||||
|
||||
### Требования
|
||||
|
||||
- **Node.js**: 22.22.3+, 24.15.0+ или 25.9.0+ в пределах соответствующей основной версии (рекомендуется Node 24 LTS)
|
||||
- **Менеджер пакетов**: pnpm 9+ (рекомендуется) или npm
|
||||
|
||||
### Структура проекта
|
||||
|
||||
```
|
||||
ClawX/
|
||||
├── electron/ # Главный процесс Electron
|
||||
│ ├── api/ # Маршрутизатор API и обработчики Main
|
||||
│ │ └── routes/ # Модули маршрутов RPC/HTTP прокси
|
||||
│ ├── services/ # Службы провайдеров, секретов и среды выполнения
|
||||
│ │ ├── providers/ # Логика синхронизации моделей provider/account
|
||||
│ │ └── secrets/ # Связка ключей ОС и хранилище секретов
|
||||
│ ├── shared/ # Общие схемы провайдеров и константы
|
||||
│ │ └── providers/
|
||||
│ ├── main/ # Точка входа приложения, окна, регистрация IPC
|
||||
│ ├── gateway/ # Менеджер процесса шлюза OpenClaw
|
||||
│ ├── preload/ # Безопасный IPC-мост
|
||||
│ └── utils/ # Утилиты (хранилище, аутентификация, пути)
|
||||
├── src/ # Процесс рендерера React
|
||||
│ ├── lib/ # Унифицированный фронтенд API и модель ошибок
|
||||
│ ├── stores/ # Хранилища Zustand (settings/chat/gateway)
|
||||
│ ├── components/ # Переиспользуемые UI-компоненты
|
||||
│ ├── pages/ # Setup/Dashboard/Chat/Channels/Skills/Cron/Settings
|
||||
│ ├── i18n/ # Ресурсы локализации
|
||||
│ └── types/ # Определения типов TypeScript
|
||||
├── tests/
|
||||
│ ├── e2e/ # Сквозные дымовые тесты Playwright Electron
|
||||
│ └── unit/ # Модульные/интеграционные тесты Vitest
|
||||
├── resources/ # Статические ресуры (иконки, изображения)
|
||||
└── scripts/ # Скрипты сборки и утилит
|
||||
```
|
||||
|
||||
### Доступные команды
|
||||
|
||||
```bash
|
||||
# Разработка
|
||||
pnpm run init # Установить зависимости + скачать uv
|
||||
pnpm dev # Запуск с горячей перезагрузкой (автоподготовка упакованных навыков при отсутствии)
|
||||
|
||||
# Качество кода
|
||||
pnpm lint # Запустить ESLint
|
||||
pnpm typecheck # Проверка типов TypeScript
|
||||
|
||||
# Тестирование
|
||||
pnpm test # Запустить модульные тесты
|
||||
pnpm run test:e2e # Запустить E2E дымовые тесты Electron с Playwright
|
||||
pnpm run test:e2e:headed # Запустить E2E тесты Electron с видимым окном
|
||||
pnpm run comms:replay # Вычислить метрики повторного воспроизведения коммуникаций
|
||||
pnpm run comms:baseline # Обновить базовый снимок коммуникаций
|
||||
pnpm run comms:compare # Сравнить метрики воспроизведения с базовыми порогами
|
||||
|
||||
# Сборка и упаковка
|
||||
pnpm run build:vite # Собрать только фронтенд
|
||||
pnpm build # Полная production-сборка (с ресурсами упаковки)
|
||||
pnpm package # Упаковать для текущей платформы (включает предустановленные навыки)
|
||||
pnpm package:mac # Упаковать для macOS
|
||||
pnpm package:win # Упаковать для Windows
|
||||
pnpm package:linux # Упаковать для Linux
|
||||
```
|
||||
|
||||
На headless Linux запускайте тесты Electron под сервером отображения, например `xvfb-run -a pnpm run test:e2e`.
|
||||
|
||||
### Проверка регрессии коммуникаций
|
||||
|
||||
Когда PR изменяет пути коммуникации (события шлюза, поток отправки/получения чата, доставка каналов или откат транспорта), запустите:
|
||||
|
||||
```bash
|
||||
pnpm run comms:replay
|
||||
pnpm run comms:compare
|
||||
```
|
||||
|
||||
`comms-regression` в CI проверяет обязательные сценарии и пороги.
|
||||
|
||||
### E2E-тесты Electron
|
||||
|
||||
Сьют Playwright Electron запускает упакованный рендерер и главный процесс из `dist/` и `dist-electron/`, поэтому не требует предварительного ручного запуска `pnpm dev`.
|
||||
|
||||
`pnpm run test:e2e` автоматически:
|
||||
|
||||
- собирает рендерер и пакеты Electron с `pnpm run build:vite`
|
||||
- запускает Electron в изолированном режиме E2E с временным `HOME`
|
||||
- использует временный каталог `userData` ClawX
|
||||
- пропускает тяжёлые побочные эффекты запуска, такие как автозапуск шлюза, установку упакованных навыков, создание трея и автоустановку CLI
|
||||
|
||||
Первые два базовых спецификации покрывают:
|
||||
|
||||
- видимость мастера настройки при первом запуске на чистом профиле
|
||||
- пропуск настройки и навигация на страницу Models внутри приложения Electron
|
||||
|
||||
Добавляйте будущие потоки Electron в `tests/e2e/` и переиспользуйте общий fixture в `tests/e2e/fixtures/electron.ts`.
|
||||
|
||||
### Технологический стек
|
||||
|
||||
| Уровень | Технология |
|
||||
|----------------|-------------------------------|
|
||||
| Среда выполнения | Electron 40+ |
|
||||
| UI-фреймворк | React 19 + TypeScript |
|
||||
| Стилизация | Tailwind CSS + shadcn/ui |
|
||||
| Состояние | Zustand |
|
||||
| Сборка | Vite + electron-builder |
|
||||
| Тестирование | Vitest + Playwright |
|
||||
| Анимация | Framer Motion |
|
||||
| Иконки | Lucide React |
|
||||
|
||||
---
|
||||
|
||||
## Участие
|
||||
|
||||
Мы приветствуем вклад сообщества! Исправления багов, новые функции, улучшения документации или переводы — каждый вклад делает ClawX лучше.
|
||||
|
||||
### Как внести вклад
|
||||
|
||||
1. **Сделайте форк** репозитория
|
||||
2. **Создайте** ветку функции (`git checkout -b feature/amazing-feature`)
|
||||
3. **Зафиксируйте** изменения с понятными сообщениями
|
||||
4. **Отправьте** в свою ветку
|
||||
5. **Откройте** Pull Request
|
||||
|
||||
### Руководящие принципы
|
||||
|
||||
- Следуйте существующему стилю кода (ESLint + Prettier)
|
||||
- Пишите тесты для нового функционала
|
||||
- Обновляйте документацию по мере необходимости
|
||||
- Держите коммиты атомарными и описательными
|
||||
|
||||
---
|
||||
|
||||
## Благодарности
|
||||
|
||||
ClawX построен на плечах отличных проектов с открытым исходным кодом:
|
||||
|
||||
- [OpenClaw](https://github.com/OpenClaw) – Среда выполнения AI-агентов
|
||||
- [Electron](https://www.electronjs.org/) – Кроссплатформенный десктоп-фреймворк
|
||||
- [React](https://react.dev/) – Библиотека UI-компонентов
|
||||
- [shadcn/ui](https://ui.shadcn.com/) – Красиво спроектированные компоненты
|
||||
- [Zustand](https://github.com/pmndrs/zustand) – Легковесное управление состоянием
|
||||
|
||||
---
|
||||
|
||||
## Сообщество
|
||||
|
||||
Присоединяйтесь к нашему сообществу, чтобы общаться с другими пользователями, получать поддержку и делиться опытом.
|
||||
|
||||
| WeChat Enterprise | Feishu Group | Discord |
|
||||
| :---: | :---: | :---: |
|
||||
| <img src="src/assets/community/wecom-qr.png" width="150" alt="WeChat QR Code" /> | <img src="src/assets/community/feishu-qr.png" width="150" alt="Feishu QR Code" /> | <img src="src/assets/community/20260212-185822.png" width="150" alt="Discord QR Code" /> |
|
||||
|
||||
### Партнёрская программа ClawX 🚀
|
||||
|
||||
Мы запускаем Партнёрскую программу ClawX и ищем партнёров, которые могут помочь представить ClawX большему числу клиентов, особенно тем, у кого есть потребности в кастомных AI-агентах или автоматизации.
|
||||
|
||||
Партнёры помогают связывать нас с потенциальными пользователями и проектами, а команда ClawX предоставляет полную техническую поддержку, кастомизацию и интеграцию.
|
||||
|
||||
Если вы работаете с клиентами, заинтересованными в AI-инструментах или автоматизации, мы будем рады сотрудничеству.
|
||||
|
||||
Напишите нам в DM или на [public@valuecell.ai](mailto:public@valuecell.ai) для получения дополнительной информации.
|
||||
|
||||
---
|
||||
|
||||
## История звёзд
|
||||
|
||||
<p align="center">
|
||||
<img src="https://api.star-history.com/svg?repos=ValueCell-ai/ClawX&type=Date" alt="Star History Chart" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Лицензия
|
||||
|
||||
ClawX выпускается под [лицензией MIT](LICENSE). Вы можете свободно использовать, модифицировать и распространять это программное обеспечение.
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<sub>Создано с ❤️ командой ValueCell</sub>
|
||||
</p>
|
||||
+183
-48
@@ -30,7 +30,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> | 简体中文 | <a href="README.ja-JP.md">日本語</a>
|
||||
<a href="README.md">English</a> | 简体中文 | <a href="README.ja-JP.md">日本語</a> | <a href="README.ru-RU.md">Русский</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -43,6 +43,8 @@
|
||||
|
||||
ClawX 预置了最佳实践的模型供应商配置,原生支持 Windows 平台以及多语言设置。当然,你也可以通过 **设置 → 高级 → 开发者模式** 来进行精细的高级配置。
|
||||
|
||||
<p align="center"><strong style="font-size:1.1em; text-decoration: underline;">如需完整的企业版、专属服务支持或面向您业务场景的定制化落地辅导,请联系 <a href="mailto:public@valuecell.ai">public@valuecell.ai</a>。</strong></p>
|
||||
|
||||
---
|
||||
|
||||
## 截图预览
|
||||
@@ -82,6 +84,7 @@ ClawX 预置了最佳实践的模型供应商配置,原生支持 Windows 平
|
||||
| 复杂的命令行配置 | 一键安装,配合引导式设置向导 |
|
||||
| 手动编辑配置文件 | 可视化设置界面,实时校验 |
|
||||
| 进程管理繁琐 | 自动管理网关生命周期 |
|
||||
| 应用更新 | 启动时检查新版本,并在下载或安装前提示确认 |
|
||||
| 多 AI 供应商切换 | 统一的供应商配置面板 |
|
||||
| 技能/插件安装复杂 | 内置技能市场与管理界面 |
|
||||
|
||||
@@ -91,6 +94,18 @@ ClawX 直接基于官方 **OpenClaw** 核心构建。无需单独安装,我们
|
||||
|
||||
我们致力于与上游 OpenClaw 项目保持严格同步,确保你始终可以使用官方发布的最新功能、稳定性改进和生态兼容性。
|
||||
|
||||
打开开发者模式且当前 runtime 为 OpenClaw 时,侧边栏还会提供原生 Dreams 页面,可在 ClawX 内查看 OpenClaw 记忆回顾、梦境日记,并执行基础维护操作;需要更深诊断时仍可从该页面打开完整 OpenClaw Dreams UI。
|
||||
|
||||
ClawX 现在也包含 runtime 抽象层。OpenClaw 仍是默认 runtime 和回滚路径,你可以在 **设置 → 网关 → Runtime** 切换到可选的内置 `cc-connect` runtime。打包产物会同时内置 cc-connect 二进制和 OpenAI Codex 原生 CLI bundle;runtime 启动不依赖全局安装、PATH 二进制或运行时下载。ClawX 会把可跨升级复用的 app 配置、凭据、runtime 数据、skills 和 workspace 放在 `~/.clawx`(或 `CLAWX_DATA_HOME`),不会自动修改 `~/.cc-connect`。GUI chat 会通过 cc-connect BridgePlatform 连接到 Codex project agent;托管 project 固定使用 cc-connect 的 Codex app-server stdio backend,让实时工具进度可以直接驱动共用的 Chat execution graph。当 cc-connect 公共历史缺少频道会话的工具数据包时,ClawX 会从匹配的本地 Codex transcript 补全历史,并将匹配范围限制在该会话所属 Agent 的 workspace。审批按钮和 cc-connect card 选项都会显示在执行图中,响应统一通过 cc-connect 公共 `card_action` 协议返回。Runtime 生成的图片、文件、音频和视频包也通过 BridgePlatform 返回,并持续显示为 Chat 附件。每个 Agent 默认使用全自动模式,也可以在 Agent 的模型/runtime 设置中独立选择“需要审批”(`suggest`)。新 agent 使用 `~/.clawx/workspaces/agents/<id>`;已有 OpenClaw workspace 可以按原路径复用,ClawX 不移动也不接管它。Provider/model、原生 cron 任务和已启用 skills 会同步到托管的 cc-connect/Codex runtime。
|
||||
|
||||
Agent 和频道设置以 `~/.clawx` 为唯一 canonical 数据源。cc-connect 处于启用状态时,保存设置不会改写 `~/.openclaw/openclaw.json`;切回 OpenClaw 后,Gateway 启动前会重新生成这份兼容投影。
|
||||
|
||||
在 cc-connect 模式下,Codex provider 同步支持 OpenAI API Key、OpenAI OAuth/Codex、Ollama,以及暴露 Responses API 的 OpenAI-compatible Custom provider。Custom provider header 会以环境变量引用写入托管配置,避免持久化密钥或 session header。配置为 Chat Completions 的 Custom provider 会在 chat 投递前被明确标记为不支持,因为这条路径使用 Codex 的 Responses wire API。
|
||||
|
||||
每个 OAuth provider account 都有独立的托管 `CODEX_HOME`。runtime 启动不会自动采用用户全局 Codex 登录;必须对选中的 account 显式执行 Codex OAuth 导入。
|
||||
|
||||
cc-connect 也负责消息平台桥接。当 cc-connect 是当前 runtime 时,频道状态探测会通过 runtime 抽象层路由,而不是继续固定查询 OpenClaw Gateway;已配置的频道账号会同步到其绑定 agent 所属的 cc-connect project,频道保存/删除会通过 cc-connect Management API reload 托管配置,在可行时无需完整重启 runtime 就让 platform 变更生效;开发者模式侧边栏的页面入口会打开 cc-connect Web Admin,OpenClaw Dreams 入口仍只在 OpenClaw runtime 下显示。
|
||||
|
||||
---
|
||||
|
||||
## 功能特性
|
||||
@@ -99,27 +114,49 @@ ClawX 直接基于官方 **OpenClaw** 核心构建。无需单独安装,我们
|
||||
从安装到第一次 AI 对话,全程通过直观的图形界面完成。无需终端命令,无需 YAML 文件,无需到处寻找环境变量。
|
||||
|
||||
### 💬 智能聊天界面
|
||||
通过现代化的聊天体验与 AI 智能体交互。支持多会话上下文、消息历史记录、Markdown 富文本渲染,以及在多 Agent 场景下通过主输入框中的 `@agent` 直接路由到目标智能体。
|
||||
通过现代化的聊天体验与 AI 智能体交互。支持多会话上下文、消息历史记录、Markdown 富文本渲染(包括 GitHub 风格表格以及由 KaTeX 渲染的 LaTeX 数学公式:`$行内$`、`$$块级$$`、`\(行内\)` 和 `\[块级\]`),以及在多 Agent 场景下通过主输入框中的 `@agent` 直接路由到目标智能体。
|
||||
从输入框插入的技能会以 `/技能名` 卡片形式显示;点击卡片可在右侧预览栏打开并阅读该技能的 `SKILL.md`。
|
||||
当你使用 `@agent` 选择其他智能体时,ClawX 会直接切换到该智能体自己的对话上下文,而不是经过默认智能体转发。各 Agent 工作区默认彼此分离,但更强的运行时隔离仍取决于 OpenClaw 的 sandbox 配置。
|
||||
会话侧边栏现在以工作空间优先组织:默认工作空间固定在最上方,其它工作空间按自然顺序排列,每个工作空间都可折叠或继续加载更多会话。AI 回复期间,会话行显示加载指示器;未查看的回复完成后显示蓝点;打开会话后恢复显示相对活跃时间,悬停时仍会露出操作按钮。导入的工作空间可从侧边栏标题处重命名,新名称会同步显示在对话输入框下方,同时悬浮标题仍可查看文件系统路径。如果当前所选会话存在有效工作空间,新对话会继承该工作空间,并在首次发送前保持可编辑。对于可编辑的新对话或未绑定对话,输入框的工作空间卡片会打开一个小菜单,列出最近使用及现有会话中的工作空间,并可切回默认工作空间或选择其它目录。如果保存的工作空间文件夹已被移动或删除,Chat 会暂停创建会话并提示选择现有文件夹,而不会持续重试失效路径。不可用的非默认工作空间会在侧边栏显示标记,并可在确认后删除;该操作会永久删除分组中的全部会话。OpenClaw 生成的 UUID 加日期兜底标题只有在与该会话 ID 匹配时才会被视为缺失标题,随后改用会话的首条用户消息展示,而不会被持久化为会话名称。
|
||||
每个 Agent 还可以单独覆盖自己的 `provider/model` 运行时设置;未覆盖的 Agent 会继续继承全局默认模型。
|
||||
|
||||
Chat 右侧面板的工作空间和预览选项卡支持以只读方式预览 `.docx` 和 `.pptx` 文件。旧版 `.doc` 和 `.ppt` 文件不会在应用内预览,而是继续通过操作系统打开。DOCX 的分页效果可能与 Microsoft Word 不同;PPTX 预览不支持动画、切换效果或媒体播放。超过 20 MB 的 Office 文件不会在应用内预览。
|
||||
|
||||
### 单页面 Web 浏览器
|
||||
Chat 右侧面板包含四个选项卡:工作空间、预览、变更和网页浏览器。网页浏览器会在首次使用时延迟创建一个实时页面;关闭面板、切换面板选项卡、切换聊天会话或前往 ClawX 的其它路由时,页面只会隐藏并继续运行,因此脚本、网络活动、音频和资源占用都可能持续。专用持久会话会在应用重启后保留 Cookie 和站点存储,但每次启动都从 `about:blank` 开始,不恢复上次的 URL、页面状态或导航历史。页面提供网站图标时,图标会显示在标题左侧;没有图标时,同尺寸占位图标会保持标题对齐,编辑地址时则隐藏整个图标位。该功能不提供额外浏览器标签页或窗口、书签、持久化历史、密码管理器或自动填充管理。
|
||||
|
||||
顶层导航支持 HTTP、HTTPS 和明确输入的标准 `file:///` URL;普通文件系统路径及其它协议会被拒绝。打开本地文件会在 Chromium 的常规安全规则下向嵌入页面暴露其中可读取的内容;对 `file:` URL 使用**在系统浏览器中打开**时,操作系统也可能改用文件关联应用,而不是浏览器。允许的弹窗目标会替换当前页面,不会创建子窗口;这种同页面回退无法保留 `window.opener`、返回的窗口句柄、先打开空白页再写入内容的脚本弹窗,也不能完整保持 POST 请求体、referrer、命名窗口和窗口特性行为。
|
||||
|
||||
下载完全沿用 Electron 和操作系统的默认行为。根据平台不同,系统可能显示原生“保存”对话框并需要用户操作;ClawX 不会指定自定义路径,也不提供下载进度、历史或管理界面。摄像头和麦克风权限会对每次请求显示原生“允许/拒绝”提示,且不会记住选择。剪贴板访问允许使用;地理位置、屏幕捕获、通知及其它权限均会被拒绝。
|
||||
|
||||
**清除 Cookie**会删除浏览器会话中所有来源的 Cookie,同时保留缓存和站点存储。**清除网站数据**会删除所有来源的 HTTP/Chromium 缓存、Cache Storage、Local Storage、IndexedDB 和 Service Worker,同时保留 Cookie 与已下载文件。浏览器流量使用 Electron/Chromium 的系统代理解析;ClawX 客户端代理设置不会同步到该浏览器会话,修改这些设置也不会重新配置它。
|
||||
|
||||
### 📡 多频道管理
|
||||
同时配置和监控多个 AI 频道。每个频道独立运行,允许你为不同任务运行专门的智能体。
|
||||
现在每个频道支持多个账号,并可在 Channels 页面直接完成账号绑定到 Agent 与默认账号切换。
|
||||
对于自定义频道账号 ID,ClawX 现在会强制校验 OpenClaw 兼容的规范格式(`[a-z0-9_-]`、小写、最长 64 位、且必须以字母或数字开头),避免路由匹配异常。
|
||||
ClawX 现在还内置了腾讯官方个人微信渠道插件,可直接在 Channels 页面通过内置二维码流程完成微信连接。
|
||||
|
||||
### ⏰ 定时任务自动化
|
||||
调度 AI 任务自动执行。定义触发器、设置时间间隔,让 AI 智能体 7×24 小时不间断工作。
|
||||
现在定时任务页面已经可以直接配置外部投递,统一拆成“发送账号”和“接收目标”两个下拉选择。对于已支持的通道,接收目标会从通道目录能力或已知会话历史中自动发现,不需要再手动修改 `jobs.json`。任务的消息输入框也支持像主对话框那样以内联 `/skill` 令牌的方式插入技能(按所选智能体范围加载),让定时提示词可以直接触发技能。调度选择器现在分为**周期**和**单次**两个选项卡:周期支持每小时、每天、工作日、每周、自定义(原始 cron)等频率,并内置时间/星期选择;单次则在所选日期(显示星期)和时间执行一次。单次任务必须设置为未来时间,并会在执行完成后由运行时自动清除。
|
||||
当 runtime 异步接受**立即运行**时,ClawX 会保持触发确认非阻塞,并在后台刷新 runtime 自己管理的任务,直到 Cron 卡片显示最新完成结果或达到有界停止条件。
|
||||
|
||||
|
||||
### 🧩 可扩展技能系统
|
||||
通过预构建的技能扩展 AI 智能体的能力。在集成的技能面板中浏览、安装和管理技能——无需包管理器。
|
||||
ClawX 还会内置预装完整的文档处理技能(`pdf`、`xlsx`、`docx`、`pptx`),在启动时自动部署到托管技能目录(默认 `~/.openclaw/skills`),并在首次安装时默认启用。额外预装技能(`find-skills`、`self-improving-agent`、`tavily-search`、`brave-web-search`、`bocha-skill`)也会默认启用;若缺少必需的 API Key,OpenClaw 会在运行时给出配置错误提示。
|
||||
Skills 页面可展示来自多个 OpenClaw 来源的技能(托管目录、workspace、额外技能目录),并显示每个技能的实际路径,便于直接打开真实安装位置。
|
||||
|
||||
重点搜索技能所需环境变量:
|
||||
- `BRAVE_SEARCH_API_KEY`:用于 `brave-web-search`
|
||||
- `TAVILY_API_KEY`:用于 `tavily-search`(上游运行时也可能支持 OAuth)
|
||||
- `BOCHA_API_KEY`:用于 `bocha-skill`
|
||||
通过预构建的技能扩展 AI 智能体的能力。集成的 Skills 页面采用“本地优先”方式:会扫描托管目录与 workspace 技能目录,并且无需依赖 Gateway 即可启用或停用技能;在企业扩展接管时,也可以显示扩展提供的 marketplace。
|
||||
ClawX 还会内置预装完整的文档处理技能(`pdf`、`xlsx`、`docx`、`pptx`),在启动时自动部署到托管技能目录(默认 `~/.openclaw/skills`),并在首次安装时默认启用。
|
||||
Skills 页面可展示来自多个 OpenClaw 来源的技能(托管目录、workspace、额外技能目录),并显示每个技能的实际路径,便于直接打开真实安装位置。对于 OpenClaw 自带的 bundled skills,社区版现在在打包产物里只保留并展示 `skill-creator`;开发模式和打包版启动时都会直接清理其它 bundled skill,同时把这些已删除 bundled skill 在 `openclaw.json` 中残留的旧配置一并移除。
|
||||
当 cc-connect runtime 处于启用状态时,ClawX 会把已启用的本地 skills 镜像到 app userData 下托管的 Codex home 中,让内置 Codex agent 使用同一套技能,而不读取全局 skill 目录。
|
||||
|
||||
### 🔐 安全的供应商集成
|
||||
连接多个 AI 供应商(OpenAI、Anthropic 等),凭证安全存储在系统原生密钥链中。OpenAI 同时支持 API Key 与浏览器 OAuth(Codex 订阅)登录。
|
||||
连接多个 AI 供应商(OpenAI、Anthropic、Z.AI / GLM 等),凭证安全存储在系统原生密钥链中。OpenAI 同时支持 API Key 与浏览器 OAuth(Codex 订阅)登录。
|
||||
在开发者模式下,独立的“图像生成”页面支持配置 OpenAI 兼容生图端点(Base URL、API Key 和模型名,例如 `gpt-image-2`),生图请求会走专用的 `/v1/images/generations` 服务,聊天仍继续使用正常的 OpenAI Provider。
|
||||
如果你通过 **自定义(Custom)Provider** 对接 OpenAI-compatible 网关,可以在 **设置 → AI Providers → 编辑 Provider** 中配置自定义 `User-Agent`,以提高兼容性。
|
||||
编辑或切换 Provider 时,ClawX 会保留已有的模型级能力元数据,例如 `input: ["text", "image"]`。新选择的自定义 Provider 模型会使用与 OpenClaw onboarding 一致的图片输入能力推断;未知模型默认按纯文本模型处理。
|
||||
自定义 Provider 的模型行还会写入显式的 `contextWindow`(按模型系列推断,例如 `gpt-5.x` → 272k),旧版本保存的模型行会在启动时自动回填,使 OpenClaw 能在长会话超限前主动压缩上下文,避免出现 "Context overflow" 报错。当你没有配置 compaction 时,ClawX 会默认写入 `agents.defaults.compaction.mode = "safeguard"` 和 `reserveTokensFloor = 50000`;你手动配置过的模型行或压缩配置永远不会被修改(仅可能回填缺失的 `reserveTokensFloor`)。
|
||||
Z.AI(国内站 / 国际站)会映射到 OpenClaw 内置的 `zai` 供应商(`ZAI_API_KEY`),默认模型为 `glm-5.2`。可通过 Code Plan 预设切换到编码套餐端点(`…/api/coding/paas/v4`),或使用普通 API 端点(`…/api/paas/v4`);国内站与国际站互斥,因为它们共享同一个 OpenClaw 运行时 key。
|
||||
如果兼容网关的 `/models` 因非鉴权原因不可用,ClawX 会在校验 API Key 时自动降级为轻量的 `/chat/completions` 或 `/responses` 探测。
|
||||
|
||||
### 🌙 自适应主题
|
||||
支持浅色模式、深色模式或跟随系统主题。ClawX 自动适应你的偏好设置。
|
||||
@@ -127,6 +164,9 @@ Skills 页面可展示来自多个 OpenClaw 来源的技能(托管目录、wor
|
||||
### 🚀 开机启动控制
|
||||
在 **设置 → 通用** 中,你可以开启 **开机自动启动**,让 ClawX 在系统登录后自动启动。
|
||||
|
||||
### 🔔 更新提示
|
||||
ClawX 可以在启动时自动检查新版本。发现更新后会显示应用内提示;只有在你选择操作后,才会下载或安装更新。
|
||||
|
||||
---
|
||||
|
||||
## 快速上手
|
||||
@@ -165,12 +205,14 @@ pnpm dev
|
||||
3. **技能包** – 选择适用于常见场景的预配置技能
|
||||
4. **验证** – 在进入主界面前测试你的配置
|
||||
|
||||
如果系统语言在支持列表中,向导会默认选中该语言;否则回退到英文。
|
||||
|
||||
> Moonshot(Kimi)说明:ClawX 默认保持开启 Kimi 的 web search。
|
||||
> 当配置 Moonshot 后,ClawX 也会将 OpenClaw 配置中的 Kimi web search 同步到中国区端点(`https://api.moonshot.cn/v1`)。
|
||||
|
||||
### 代理设置
|
||||
|
||||
ClawX 内置了代理设置,适用于需要通过本地代理客户端访问外网的场景,包括 Electron 本身、OpenClaw Gateway,以及 Telegram 这类频道的联网请求。
|
||||
ClawX 内置了代理设置,适用于需要通过本地代理客户端访问外网的场景,包括 Electron 本身、OpenClaw Gateway、可选的 cc-connect/Codex runtime,以及 Telegram 这类频道的联网请求。
|
||||
|
||||
打开 **设置 → 网关 → 代理**,配置以下内容:
|
||||
|
||||
@@ -191,8 +233,12 @@ ClawX 内置了代理设置,适用于需要通过本地代理客户端访问
|
||||
- 只填写 `host:port` 时,会按 HTTP 代理处理。
|
||||
- 高级代理项留空时,会自动回退到“代理服务器”。
|
||||
- 保存代理设置后,Electron 网络层会立即重新应用代理,并自动重启 Gateway。
|
||||
- 在 cc-connect runtime 模式下,Codex 子进程会继承同一组 `HTTP_PROXY`、`HTTPS_PROXY`、`ALL_PROXY` 和绕过规则环境变量。
|
||||
- 如果启用了 Telegram,ClawX 还会把代理同步到 OpenClaw 的 Telegram 频道配置中。
|
||||
- 在 **设置 → 高级 → 开发者** 中,可以直接运行 **OpenClaw Doctor**,执行 `openclaw doctor --json` 并在应用内查看诊断输出。
|
||||
- 当 ClawX 代理处于关闭状态时,Gateway 的常规重启会保留已有的 Telegram 频道代理配置。
|
||||
- 如果你要明确清空 OpenClaw 中的 Telegram 代理,请在关闭代理后点一次“保存代理设置”。
|
||||
- 在 **设置 → 高级 → 开发者** 中,Runtime Doctor 会在 OpenClaw 模式执行 `openclaw doctor --json`;在 cc-connect 模式组合执行内置的 `cc-connect doctor user-isolation` 与 `codex doctor --json`,并把权限为 0600 的审计报告写入 ClawX 托管 runtime 目录。Doctor Fix 仍只支持 OpenClaw。
|
||||
- 在 Windows 打包版本中,内置的 `openclaw` CLI/TUI 会通过随包分发的 `node.exe` 入口运行,以保证终端输入行为稳定。
|
||||
|
||||
---
|
||||
|
||||
@@ -200,47 +246,67 @@ ClawX 内置了代理设置,适用于需要通过本地代理客户端访问
|
||||
|
||||
ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用统一客户端抽象,协议选择与进程生命周期由 Electron 主进程统一管理:
|
||||
|
||||
```┌─────────────────────────────────────────────────────────────────┐
|
||||
│ ClawX 桌面应用 │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||
Chat 传输会随当前 runtime 切换,但 Renderer 始终只经过同一个边界。OpenClaw Chat 使用由 Electron Main 持有的 ACP stdio bridge,Renderer 接收类型化 host events 并渲染内存中的 ACP timeline;cc-connect Chat 则由 `RuntimeManager` 通过 cc-connect BridgePlatform 分派,包括 session history、progress、approval 与 generated media。两种模式都使用同一套 Host API facade,Renderer 不会直接调用 Codex。非 Chat 能力也通过 runtime provider 分派,OpenClaw 专属操作只保留在 OpenClaw adapter 内。
|
||||
|
||||
打开其它会话或页面时,尚未完成的 ACP 回复仍会继续流式接收。若在回复完成前返回,ClawX 会恢复最新的内存 timeline 并继续显示实时输出;回复完成后,普通 ACP 历史回放仍是唯一事实来源。
|
||||
|
||||
ACP assistant 回合会显示整轮耗时。Live 计时跟随客户端观测到的 prompt 生命周期,并在应用内导航后保持连续;历史耗时由 Electron Main 根据有界的 OpenClaw transcript 时间戳计算,而且只能标注 ACP 回放已经恢复出的回合。
|
||||
|
||||
ACP Chat 会将标准 ACP resource 渲染为附件。用户选择的图片会显示为缩略图,并在悬停蒙层中显示文件名;其它可用的附件卡片会显示文件名,以及灰色、可截断的来源路径。当前 OpenClaw ACP adapter 遗漏 assistant 媒体时,显式的 assistant `MEDIA:` 指令也可恢复为附件卡片,且不会显示原始指令。现有本地文件引用(包括当前 workspace 外的路径)在每次预览或打开前,都会由 Electron Main 按精确的 session 和 generation 重新验证。AI 生成且可预览的本地附件(包括不超过 20 MB 的 `.docx` 和 `.pptx` 文件)会保留主要的只读应用内预览操作,并提供次级菜单,可通过兼容应用打开,或在 Finder、文件资源管理器或系统文件管理器中显示。对于本地 HTML 附件,该菜单第一项会在右侧网页浏览器中打开文件 URL。Office 预览在此处也有相同限制:`.doc` 和 `.ppt` 仍通过系统应用打开,DOCX 的分页效果可能与 Microsoft Word 不同,PPTX 的动画、切换效果和媒体播放不受支持。兼容应用发现仅在 macOS 和 Windows 上可用;在 Linux 上或发现失败时,会静默降级为仅显示文件位置。其它本地文件(包括超过 20 MB 的 Office 文件)会在用户点击后通过系统应用打开;远程 HTTP 和 HTTPS 附件会在用户点击后从外部打开。普通文本中的裸路径或行内路径不会被当作附件。
|
||||
|
||||
ACP Chat 也可在 runtime 以可信结构化媒体投递图像生成结果时显示生成图片预览。对于可信的 OpenClaw internal-UI 投递和与生图任务关联的最终回复,ClawX 会保留原始的用户可见完成文案,包括只有文本的失败说明,而不会统一替换成通用图片文案。历史 OpenClaw 回放中,assistant 的图片 `MEDIA:` 标记只有在同一会话已记录图像生成任务启动后才会进入内联图片体验。ClawX 通过 Electron Main 的主机媒体处理加载预览,而不是让 Renderer 任意访问文件系统。标准 ACP 图片和 resource 内容仍是首选路径,并会直接渲染。
|
||||
|
||||
### ACP 文件活动语义
|
||||
|
||||
- 文件活动由成功且已完成的 OpenClaw `write`、`edit` 和 `apply_patch` 调用投影而来。工具识别方式与 OpenClaw 官方 Chat UI 保持一致;仅接收已完成调用的筛选规则是 ClawX 特有的。
|
||||
- 已创建和已修改的活动行与可预览的 assistant 附件共用同一种文件卡片外壳和**打开方式**菜单,同时保留状态文字及可用的 `+/-` 统计。对于 HTML 文件,菜单第一项会在右侧**网页浏览器**中打开本地文件 URL 并激活该选项卡;已删除的活动行只保留 **Changes** 操作。应用列表、指定应用打开和显示文件位置都会由 Electron Main 根据 workspace 根目录与相对路径分别重新验证;工具路径不会因此变成附件,Renderer 也不会获得规范化系统路径。
|
||||
- `write` 按工具声明的语义显示:视为创建,并展示为全部新增的差异,即使该路径可能已经存在。
|
||||
- **Changes** 是按时间顺序记录工具声明活动的会话级记录,不是 Git 输出,也不是相对于已验证源码基线的差异。
|
||||
- 对每个文件,Changes 在每轮助手回复中最多展示一个 diff 编辑器。可安全串联的片段会合并,独立片段会拼接到同一个编辑器中,但不会被描述为基于完整文件基线的差异。
|
||||
- Shell 命令、脚本、用户或 IDE 产生的副作用不会被检测。
|
||||
- 完整的 ACP 回放可以恢复已记录的文件活动;如果回放不完整,ClawX 不会通过回退推断来补造缺失活动。
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────────────┐
|
||||
│ ClawX 桌面应用 │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Electron 主进程 │ │
|
||||
│ │ • 窗口与应用生命周期管理 │ │
|
||||
│ │ • 窗口与应用生命周期管理 │ │
|
||||
│ │ • 网关进程监控 │ │
|
||||
│ │ • 系统集成(托盘、通知、密钥链) │ │
|
||||
│ │ • 自动更新编排 │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ │ IPC(权威控制面) │
|
||||
│ │ IPC (权威控制面) │
|
||||
│ ▼ │
|
||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ │ React 渲染进程 │ │
|
||||
│ │ • 现代组件化 UI(React 19) │ │
|
||||
│ │ • Zustand 状态管理 │ │
|
||||
│ │ • 统一 host-api/api-client 调用 │ │
|
||||
│ │ • Markdown 富文本渲染 │ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ React 渲染进程 │ │
|
||||
│ │ • 现代组件化 UI(React 19) │ │
|
||||
│ │ • Zustand 状态管理 │ │
|
||||
│ │ • 统一 host-api/api-client 调用 │ │
|
||||
│ │ • Markdown 富文本渲染 │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────┬──────────────────────────────────┘
|
||||
└──────────────────────────────┬───────────────────────────────────┘
|
||||
│
|
||||
│ 主进程统一传输策略
|
||||
│(WS 优先,HTTP 次之,IPC 回退)
|
||||
│ 类型化 IPC 请求
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Host API 与主进程代理层 │
|
||||
│ │
|
||||
│ • hostapi:fetch(主进程代理,规避开发/生产 CORS) │
|
||||
│ • gateway:httpProxy(渲染进程不直连 Gateway HTTP) │
|
||||
│ • 统一错误映射与重试/退避策略 │
|
||||
│ 主进程 Host Services 与 Runtime Manager │
|
||||
│ │
|
||||
│ • host:invoke 类型化服务分发 │
|
||||
│ • 设置、文件、会话、技能、供应商、诊断服务 │
|
||||
│ • Runtime 选择、传输与进程监控 │
|
||||
└──────────────────────────────┬──────────────────────────────────┘
|
||||
│
|
||||
│ WS / HTTP / IPC 回退
|
||||
│ 主进程持有 WebSocket
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ OpenClaw 网关 │
|
||||
│ │
|
||||
│ • AI 智能体运行时与编排 │
|
||||
│ OpenClaw 网关路径(图示) │
|
||||
│ │
|
||||
│ • AI 智能体运行时与编排 │
|
||||
│ • 消息频道管理 │
|
||||
│ • 技能/插件执行环境 │
|
||||
│ • 技能/插件执行环境 │
|
||||
│ • 供应商抽象层 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
@@ -248,10 +314,23 @@ ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用
|
||||
|
||||
- **进程隔离**:AI 运行时在独立进程中运行,确保即使在高负载计算期间 UI 也能保持响应
|
||||
- **前端调用单一入口**:渲染层统一走 host-api/api-client,不感知底层协议细节
|
||||
- **主进程掌控传输策略**:WS/HTTP 选择与 IPC 回退在主进程集中处理,提升稳定性
|
||||
- **主进程掌控传输策略**:OpenClaw ACP/Gateway 传输与 cc-connect BridgePlatform 分派都由 Electron Main 持有,渲染进程通过类型化 IPC 调用 Main
|
||||
- **扩展 IPC 贡献点**:主进程扩展通过类型化 IPC 注册表贡献 host-api action,而不是挂载 HTTP route
|
||||
- **优雅恢复**:内置重连、超时、退避逻辑,自动处理瞬时故障
|
||||
- **安全存储**:API 密钥和敏感数据利用操作系统原生的安全存储机制
|
||||
- **CORS 安全**:本地 HTTP 请求由主进程代理,避免渲染进程跨域问题
|
||||
- **CORS 安全**:渲染进程不直接请求本地 Gateway 或 Host API HTTP 端点
|
||||
|
||||
### 进程模型与 Gateway 排障
|
||||
|
||||
- ClawX 基于 Electron,**单个应用实例出现多个系统进程是正常现象**(main/renderer/zygote/utility)。
|
||||
- 单实例保护同时使用 Electron 自带锁和 `~/.clawx/locks` 下的跨安装 writer lock。ClawX 会在共享数据初始化、迁移、runtime 或 scheduler 启动前取得文件锁;无法确认所有权时会拒绝启动。
|
||||
- 滚动升级期间若新旧版本混跑,单实例保护仍可能出现不对称行为。为保证稳定性,建议桌面客户端尽量统一升级到同一版本。
|
||||
- 但 OpenClaw Gateway 监听应始终保持**单实例**:`127.0.0.1:18789` 只能有一个监听者。
|
||||
- Gateway readiness 以 OpenClaw 的 `system-presence`、`health`、`status` 等核心信号为准;memory、Dreams 或频道失败会显示为能力降级,而不是全局 Gateway 故障。
|
||||
- 可用以下命令确认监听进程:
|
||||
- macOS/Linux:`lsof -nP -iTCP:18789 -sTCP:LISTEN`
|
||||
- Windows(PowerShell):`Get-NetTCPConnection -LocalPort 18789 -State Listen`
|
||||
- 点击窗口关闭按钮(`X`)默认只是最小化到托盘,并不会完全退出应用。请在托盘菜单中选择 **Quit ClawX** 执行完整退出。
|
||||
|
||||
---
|
||||
|
||||
@@ -275,16 +354,19 @@ ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用
|
||||
|
||||
### 前置要求
|
||||
|
||||
- **Node.js**:22+(推荐 LTS 版本)
|
||||
- **Node.js**:对应主版本范围内的 22.22.3+、24.15.0+ 或 25.9.0+(推荐 Node 24 LTS)
|
||||
- **包管理器**:pnpm 9+(推荐)或 npm
|
||||
- **Linux(Ubuntu/Debian)**:运行 Electron 前,请先安装所需系统库:
|
||||
```bash
|
||||
sudo apt-get install -y libnss3 libgtk-3-0 libxss1 libxtst6 libatspi2.0-0 libnotify4 xdg-utils
|
||||
```
|
||||
在 Ubuntu 24.04+ 上,部分软件包使用 `t64` 后缀,运行上述命令后 `apt` 会自动选择正确版本。
|
||||
|
||||
### 项目结构
|
||||
|
||||
```ClawX/
|
||||
├── electron/ # Electron 主进程
|
||||
│ ├── api/ # 主进程 API 路由与处理器
|
||||
│ │ └── routes/ # RPC/HTTP 代理路由模块
|
||||
│ ├── services/ # Provider、Secrets 与运行时服务
|
||||
│ ├── services/ # 类型化 Host API、Provider、Secrets 与运行时服务
|
||||
│ │ ├── providers/ # Provider/account 模型同步逻辑
|
||||
│ │ └── secrets/ # 系统钥匙串与密钥存储
|
||||
│ ├── shared/ # 共享 Provider schema/常量
|
||||
@@ -301,16 +383,19 @@ ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用
|
||||
│ ├── i18n/ # 国际化资源
|
||||
│ └── types/ # TypeScript 类型定义
|
||||
├── tests/
|
||||
│ ├── e2e/ # Playwright Electron 端到端冒烟测试
|
||||
│ └── unit/ # Vitest 单元/集成型测试
|
||||
├── resources/ # 静态资源(图标、图片)
|
||||
└── scripts/ # 构建与工具脚本
|
||||
```
|
||||
### 常用命令
|
||||
|
||||
cc-connect 真实验证可以加载本地 env 文件,但仓库内的凭据文件必须被 gitignore;仓库外 `--env-file` 路径可以使用且不会写入报告。`.env.cc-connect.local.example` 是 `.env.cc-connect.local` 的字段模板。
|
||||
|
||||
```bash
|
||||
# 开发
|
||||
pnpm run init # 安装依赖并下载 uv
|
||||
pnpm dev # 以热重载模式启动
|
||||
pnpm run init # 安装依赖并下载捆绑二进制(uv、agent-browser)
|
||||
pnpm dev # 以热重载模式启动(若缺失会自动准备预装技能包)
|
||||
|
||||
# 代码质量
|
||||
pnpm lint # 运行 ESLint 检查
|
||||
@@ -318,15 +403,65 @@ pnpm typecheck # TypeScript 类型检查
|
||||
|
||||
# 测试
|
||||
pnpm test # 运行单元测试
|
||||
pnpm run test:e2e # 运行 Electron E2E 冒烟测试
|
||||
pnpm run test:e2e:cc-connect:codex-oauth-lifecycle # 无需真实凭证验证 cc-connect Codex OAuth Host API 状态/导入/登出
|
||||
CLAWX_REAL_OAUTH_E2E=1 CLAWX_REAL_CODEX_AUTH_JSON="$HOME/.codex/auth.json" pnpm run test:e2e:cc-connect:real-oauth # 验证真实 OAuth 工具执行和 Chat execution graph
|
||||
pnpm run test:e2e:headed # 以可见窗口运行 Electron E2E 测试
|
||||
pnpm run comms:replay # 计算通信回放指标
|
||||
pnpm run comms:baseline # 刷新通信基线快照
|
||||
pnpm run comms:compare # 将回放指标与基线阈值对比
|
||||
pnpm run verify:cc-connect:local-real # 写入本地 cc-connect 真实验证前置报告
|
||||
pnpm run verify:cc-connect:local-real:run # 执行安全的本地 cc-connect 真实验证检查并写入报告
|
||||
pnpm run verify:cc-connect:local-real:oauth # CLAWX_REAL_CODEX_AUTH_JSON 包含完整 refresh token 字段时额外执行开发版 cc-connect 真实 OAuth 综合冒烟
|
||||
pnpm run verify:cc-connect:local-real:oauth-all # CLAWX_REAL_CODEX_AUTH_JSON 包含完整 refresh token 字段时额外执行开发版和打包版 cc-connect 真实 OAuth 冒烟
|
||||
pnpm run verify:cc-connect:local-real:api-key # 执行本地 OpenAI-compatible API-key chat/abort 冒烟;有真实凭证时额外执行真实 OpenAI API-key 冒烟
|
||||
pnpm run verify:cc-connect:local-real:feishu # 有凭证和 CLAWX_REAL_CODEX_AUTH_JSON 时额外执行真实飞书/Lark 生命周期冒烟
|
||||
pnpm run verify:cc-connect:local-real:feishu-inbound # 沙箱租户入站 fixture 启用时额外执行真实飞书/Lark inbound marker 冒烟
|
||||
pnpm run verify:cc-connect:local-real:scheduled-cron # 执行真实原生 exec cron;有 Codex auth 时通过 cc-connect public session history 验证原生 prompt 调度
|
||||
pnpm run verify:cc-connect:local-real:all # 执行所有可用的本地 cc-connect 真实验证路径,并写入外部门禁交接清单
|
||||
pnpm run verify:cc-connect:local-real:all-strict # 发布候选验证要求所有真实凭证和 runtime parity 覆盖都通过;失败前也会写入交接清单
|
||||
pnpm run verify:cc-connect:local-real:replacement-ready # 要求 replacement readiness 通过,但不把缺失凭证单独作为前置失败;失败前也会写入交接清单
|
||||
pnpm run verify:cc-connect:local-real:replacement-ready:check # 同样检查 readiness,但不覆盖上一次报告产物
|
||||
pnpm run verify:cc-connect:local-real:packaged-oauth # CLAWX_REAL_CODEX_AUTH_JSON 包含完整 refresh token 字段时额外执行打包版 cc-connect 真实 OAuth 冒烟
|
||||
pnpm run verify:cc-connect:local-real:external-gates:check # 非破坏性检查剩余 required external gates,不覆盖报告产物
|
||||
pnpm run verify:cc-connect:local-real:external-gates # 只运行剩余 required external gates,三项全部通过才成功
|
||||
pnpm run verify:cc-connect:local-real:handoff # 生成不含凭证的剩余外部门禁交接清单
|
||||
|
||||
# 报告写入 artifacts/cc-connect/local-real-validation-report.{json,md};
|
||||
# :all、:all-strict、:replacement-ready、:external-gates 或 :handoff 会把外部门禁交接清单写入 artifacts/cc-connect/local-real-external-gates.{md,json}。
|
||||
# JSON 交接清单可供机器读取,只包含清洗后的状态、环境变量名、命令和安全说明。
|
||||
# runtimeMatrixStatus 会把 pass/partial/fail 覆盖状态和硬门禁退出状态分开展示。
|
||||
# 使用 --no-write、replacement-ready:check 或 external-gates:check 做非破坏性门禁检查;缺失前置条件和下一步命令会以不含密钥值的形式打印。
|
||||
# validationGaps 会区分本地硬门禁缺口和完整替代所需的 follow-up 证据缺口。
|
||||
# partial 报告会包含 Next Actions,列出后续命令且不写入密钥值。
|
||||
# 真实凭证可通过未跟踪且已 gitignore 的 .env.cc-connect.local、--env-file=<path>、
|
||||
# 或 CLAWX_REAL_ENV_FILE / CLAWX_REAL_ENV_FILES 提供;显式进程环境变量优先。
|
||||
# API-key 冒烟在默认模型不可用时可设置 CLAWX_REAL_OPENAI_MODEL。
|
||||
|
||||
# 构建与打包
|
||||
pnpm run build:vite # 仅构建前端
|
||||
pnpm build # 完整生产构建(含打包资源)
|
||||
pnpm package # 为当前平台打包
|
||||
pnpm package # 为当前平台打包(包含预装技能资源)
|
||||
pnpm package:mac # 为 macOS 打包
|
||||
pnpm package:win # 为 Windows 打包
|
||||
pnpm package:linux # 为 Linux 打包
|
||||
pnpm run verify:runtime-bundles # 校验下载的 cc-connect/Codex bundle manifest 与二进制
|
||||
pnpm run verify:packaged-runtime-resources -- --resources=<路径> --platform=<darwin|win32|linux> --arch=<x64|arm64> # 校验最终 Electron runtime resources
|
||||
pnpm run smoke:cc-connect:packaged # 启动当前平台 unpacked app,验证 cc-connect 启动/状态/Cron/Doctor/回滚/清理
|
||||
```
|
||||
|
||||
在无头 Linux 环境下,Electron 测试需要显示服务;可使用 `xvfb-run -a pnpm run test:e2e`。
|
||||
|
||||
### 通信回归检查
|
||||
|
||||
当 PR 涉及通信链路(Gateway 事件、ACP Chat bridge 收发流程、Channel 投递、传输回退)时,建议执行:
|
||||
|
||||
```bash
|
||||
pnpm run comms:replay
|
||||
pnpm run comms:compare
|
||||
```
|
||||
|
||||
CI 中的 `comms-regression` 会校验必选场景与阈值。
|
||||
### 技术栈
|
||||
|
||||
| 层级 | 技术 |
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extensions": {
|
||||
"main": [
|
||||
"builtin/diagnostics"
|
||||
],
|
||||
"renderer": []
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
@@ -0,0 +1,865 @@
|
||||
# ClawX Runtime Abstraction and cc-connect Replacement Specification
|
||||
|
||||
Status: implementation contract
|
||||
Updated: 2026-07-12
|
||||
Default runtime: `openclaw`
|
||||
Optional runtime: `cc-connect` behind Developer Mode
|
||||
|
||||
## 1. Objective
|
||||
|
||||
ClawX must expose one runtime layer whose OpenClaw and cc-connect providers
|
||||
support the same product surfaces. OpenClaw remains the default and rollback
|
||||
path. cc-connect is accepted as a replacement only when chat, sessions,
|
||||
history, tools, provider credentials, Feishu/Lark, native cron, usage,
|
||||
skills, diagnostics, and packaged startup are proven through the cc-connect
|
||||
process rather than through ClawX-to-Codex shortcuts.
|
||||
|
||||
The non-negotiable execution boundary is:
|
||||
|
||||
```text
|
||||
Renderer -> Host API -> RuntimeManager -> CcConnectRuntimeProvider
|
||||
-> cc-connect Bridge/Management API -> cc-connect -> Codex
|
||||
```
|
||||
|
||||
ClawX may supply the Codex binary path, provider environment, `CODEX_HOME`,
|
||||
workspace, skills, and credentials to cc-connect. It must not spawn Codex for
|
||||
chat, parse Codex files as the production real-time event transport, or invoke
|
||||
Codex session commands directly.
|
||||
|
||||
## 2. Version and packaging decision
|
||||
|
||||
The original prototype pinned `cc-connect@1.3.2`. That package contains only a
|
||||
CLI wrapper, install script, and documentation; its postinstall downloads a
|
||||
release binary into `node_modules/cc-connect/bin`. Declaring the dependency is
|
||||
therefore insufficient for Electron packaging.
|
||||
|
||||
The replacement implementation targets stable `cc-connect@1.4.1` because its
|
||||
published runtime surface includes Bridge REST session management and a broader
|
||||
Management API. The exact binary, not upstream `main`, is the release contract.
|
||||
Every upgrade must run the contract probe before application code adopts a new
|
||||
endpoint.
|
||||
|
||||
Packaging requirements:
|
||||
|
||||
- Pin `cc-connect` exactly in `devDependencies`.
|
||||
- `scripts/bundle-cc-connect.mjs` downloads release assets for macOS x64/arm64,
|
||||
Linux x64/arm64, and Windows x64.
|
||||
- Verify `--version`, executable permission, SHA-256, platform, architecture,
|
||||
source URL, and package version in `manifest.json`.
|
||||
- Copy the verified binary to `process.resourcesPath/cc-connect/`; never run
|
||||
postinstall or download a binary at application runtime.
|
||||
- Bundle the pinned Codex CLI in `process.resourcesPath/codex/`; cc-connect is
|
||||
the only process allowed to launch it for runtime work.
|
||||
- `afterPack` must reject a target whose copied cc-connect or Codex resource is
|
||||
missing, stale, corrupted, non-executable, or inconsistent with its manifest.
|
||||
- Final unpacked artifacts must pass
|
||||
`pnpm run verify:packaged-runtime-resources -- --resources=<resources> --platform=<platform> --arch=<arch>`.
|
||||
Windows and Linux require exact packaged-binary SHA equality. macOS also
|
||||
requires exact SHA before signing; when `codesign` rewrites Mach-O metadata,
|
||||
the final verifier requires the source bundle SHA, all Mach-O section
|
||||
payloads, architecture/version, and `codesign --verify --strict` to agree.
|
||||
- macOS, Windows, and Linux packaged jobs must run a resource/startup/cleanup
|
||||
smoke before release readiness can be claimed.
|
||||
- `.github/workflows/release.yml` runs the final resource verifier for macOS
|
||||
x64/arm64, Windows x64, and Linux x64/arm64 before uploading release
|
||||
artifacts. The same release gate runs the full packaged smoke natively on
|
||||
macOS arm64, Windows x64, and Linux x64, with dedicated `macos-15-intel` and
|
||||
`ubuntu-24.04-arm` jobs for macOS x64 and Linux arm64. Publishing depends on
|
||||
all five jobs. A local run cannot replace observed CI evidence. Runner labels
|
||||
follow the [GitHub-hosted runners reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners).
|
||||
- A manual `Release` workflow dispatch is evidence-only: it disables macOS
|
||||
signing discovery and never creates a GitHub Release, uploads to OSS, or runs
|
||||
final promotion. Publishing remains tag-only. Use an alpha/beta version label
|
||||
for manual smoke so Windows also skips SignPath. Manual macOS smoke explicitly
|
||||
records that signature validation was skipped; tag builds still require strict
|
||||
signature verification before publishing.
|
||||
|
||||
Primary upstream contracts:
|
||||
|
||||
- [cc-connect usage](https://github.com/chenhg5/cc-connect/blob/v1.4.1/docs/usage.md)
|
||||
- [Management API](https://github.com/chenhg5/cc-connect/blob/v1.4.1/docs/management-api.md)
|
||||
- [Bridge protocol](https://github.com/chenhg5/cc-connect/blob/v1.4.1/docs/bridge-protocol.md)
|
||||
|
||||
## 3. Durable data and locking
|
||||
|
||||
All ClawX-owned persistent state uses one upgrade-stable root. Stable, beta,
|
||||
dev, and multiple installations may share it, but only one writer may run at a
|
||||
time.
|
||||
|
||||
```text
|
||||
~/.clawx/
|
||||
state/
|
||||
data-version.json
|
||||
migration-journal.jsonl
|
||||
locks/
|
||||
writer.lock
|
||||
app/
|
||||
settings.json
|
||||
clawx-providers.json
|
||||
runtime-config.json
|
||||
cc-connect-agent-bindings.json
|
||||
cc-connect-session-metadata.json
|
||||
credentials/
|
||||
index.json
|
||||
secrets.enc
|
||||
oauth/<provider-account-id>/codex-home/
|
||||
skills/
|
||||
installed/
|
||||
configs.json
|
||||
workspaces/
|
||||
agents/<agent-id>/
|
||||
runtimes/
|
||||
cc-connect/{config,data,media,events,logs}
|
||||
openclaw/projection-state.json
|
||||
system/electron/
|
||||
logs/
|
||||
backups/
|
||||
cache/
|
||||
```
|
||||
|
||||
`resolveClawXDataRoot()` and `getClawXDataLayout()` are the only path-building
|
||||
entry points. Production defaults to `~/.clawx`; `CLAWX_DATA_HOME` is the
|
||||
supported override. Electron `userData` becomes `~/.clawx/system/electron` and
|
||||
application logs use `~/.clawx/logs`.
|
||||
|
||||
`writer.lock` is created atomically and contains pid, owner token, app version,
|
||||
channel, executable, start time, and heartbeat time. A second installation
|
||||
shows the current owner and exits before the data layout, migrations, runtime
|
||||
manager, or scheduler can start. Failure to acquire or inspect the lock is
|
||||
fail-closed; ClawX never falls back to an uncoordinated shared-root writer.
|
||||
Stale lock recovery requires both a dead pid and an expired heartbeat;
|
||||
`force: true` deletion is forbidden.
|
||||
|
||||
Migrations are version-gated, journaled, additive, backed up, and atomic. An
|
||||
older application that cannot understand the current data version refuses to
|
||||
write. Existing Electron data is imported into `~/.clawx`; existing
|
||||
`~/.openclaw` remains external compatibility data and is never moved or
|
||||
deleted.
|
||||
|
||||
`tests/e2e/clawx-data-layout-migration.spec.ts` exercises this production
|
||||
startup order without the flat `CLAWX_USER_DATA_DIR` test compatibility
|
||||
override. It supplies an isolated legacy `--user-data-dir` before Main startup,
|
||||
uses an isolated `CLAWX_DATA_HOME`, launches Electron, and verifies `app/`,
|
||||
`system/electron`, the data version, migration journal, and retained legacy
|
||||
source on every CI platform without reading the developer's real userData. It
|
||||
then changes the legacy settings and launches again to prove the canonical
|
||||
`app/` state wins across upgrades and repeated migration attempts.
|
||||
|
||||
`tests/e2e/clawx-shared-root-single-writer.spec.ts` launches two real Electron
|
||||
processes against the same root, proves the duplicate cannot replace the live
|
||||
owner or create a window, captures first-writer UI evidence, then closes the
|
||||
owner and proves a successor process acquires the released lock.
|
||||
|
||||
`app/runtime-config.json` is the canonical Agent, binding, channel-account, and
|
||||
OpenClaw-compatible runtime metadata document. Sensitive channel fields are
|
||||
removed before this file is written and are hydrated from
|
||||
`credentials/secrets.enc` only in Main-process memory. `~/.openclaw/openclaw.json`
|
||||
is an import/export compatibility projection, not the cc-connect state owner.
|
||||
The compatibility file is imported only when canonical state does not yet
|
||||
exist. Shared saves never use its mtime to overwrite canonical state. While
|
||||
cc-connect is active they do not write the projection; the OpenClaw adapter
|
||||
rebuilds it, including vault-backed channel secrets, immediately before
|
||||
OpenClaw start or restart.
|
||||
|
||||
## 4. Runtime contracts
|
||||
|
||||
```ts
|
||||
type RuntimeKind = 'openclaw' | 'cc-connect'
|
||||
|
||||
interface RuntimeProvider {
|
||||
kind: RuntimeKind
|
||||
start(): Promise<void>
|
||||
stop(): Promise<void>
|
||||
restart(): Promise<void>
|
||||
getStatus(): RuntimeStatus
|
||||
checkHealth(options?: RuntimeHealthOptions): Promise<RuntimeHealth>
|
||||
rpc<T>(method: string, params?: unknown): Promise<T>
|
||||
sendMessageWithMedia(payload: RuntimeSendPayload): Promise<RuntimeSendResult>
|
||||
abortRun(payload: RuntimeAbortPayload): Promise<RuntimeAbortResult>
|
||||
resolveApproval(payload: RuntimeApprovalResponse): Promise<void>
|
||||
listSessions(query?: RuntimeSessionQuery): Promise<RuntimeSessionPage>
|
||||
loadHistory(query: RuntimeHistoryQuery): Promise<RuntimeHistoryPage>
|
||||
deleteSession(payload: RuntimeSessionMutation): Promise<void>
|
||||
listUsage(query?: RuntimeUsageQuery): Promise<RuntimeUsagePage>
|
||||
listLogs(query?: RuntimeLogQuery): Promise<RuntimeLogPage>
|
||||
runDoctor(mode: 'diagnose' | 'fix'): Promise<RuntimeDoctorResult>
|
||||
listCapabilities(): RuntimeCapabilities
|
||||
listOperationCapabilities(): RuntimeOperationCapabilities
|
||||
}
|
||||
```
|
||||
|
||||
`RuntimeStatus` retains Gateway-compatible process states and adds
|
||||
`runtimeKind`, version, config directory, capabilities, operation capabilities,
|
||||
and scoped health. `gateway:*` IPC/event names remain compatibility aliases,
|
||||
but their data is always supplied by the active provider.
|
||||
|
||||
Operation support is `native`, `proxy`, `degraded`, or `unsupported`.
|
||||
`degraded` means the command remains callable but has a documented parity or
|
||||
blast-radius limitation. For cc-connect v1.4.1, `chat.abort` is native: ClawX
|
||||
sends the public `/stop` command over BridgePlatform for the selected session.
|
||||
The whole runtime is restarted only as a disconnected-Bridge fallback when the
|
||||
stop command cannot be delivered. Settings displays degraded and unsupported
|
||||
operations separately from top-level capability availability.
|
||||
|
||||
Before a runtime status has published operation capabilities, renderer helpers
|
||||
retain compatibility with legacy Gateway status. Once the operation map is
|
||||
present, any undeclared method is treated as unsupported; this makes contract
|
||||
drift visible instead of allowing an unreviewed runtime call to pass through.
|
||||
|
||||
OpenClaw-specific auth, proxy mutation, Doctor Fix, Skills implementation,
|
||||
Dreams, memory repair, and Control UI remain inside the OpenClaw adapter.
|
||||
Shared services must not call `GatewayManager` or write `~/.openclaw` when
|
||||
cc-connect is active.
|
||||
|
||||
## 5. Agent, provider, model, and credential ownership
|
||||
|
||||
Provider Account is the stable credential identity. Agent bindings reference an
|
||||
account explicitly instead of encoding identity in `provider/model` strings.
|
||||
|
||||
```ts
|
||||
interface AgentRuntimeBinding {
|
||||
agentId: string
|
||||
providerAccountId: string
|
||||
model: string
|
||||
workspaceId: string
|
||||
}
|
||||
```
|
||||
|
||||
`agents.updateRuntimeBinding({ id, providerAccountId, model })` is the canonical
|
||||
Host API. The old model-only method is a compatibility adapter and fails when
|
||||
multiple accounts make the reference ambiguous.
|
||||
|
||||
Each cc-connect project resolves credential identity from the Agent's provider
|
||||
account binding and resolves model independently from that Agent's explicit
|
||||
`provider/model` override or the canonical default. Project model overrides
|
||||
replace only cc-connect/Codex model arguments; they never replace or merge the
|
||||
bound account's OAuth home or API-key environment.
|
||||
|
||||
Credential rules:
|
||||
|
||||
- Browser OAuth acquisition writes only the ClawX-owned provider account and
|
||||
encrypted secret. Runtime projection is dispatched through the active
|
||||
`RuntimeProvider`: cc-connect materializes its account-scoped managed
|
||||
`CODEX_HOME`, while OpenClaw retains its existing auth/config projection. A
|
||||
cc-connect OAuth success must never write OpenClaw config or schedule an
|
||||
OpenClaw Gateway restart.
|
||||
- A successful cc-connect browser re-login (`reason=oauth`) replaces that
|
||||
account's managed Codex auth with the newly acquired vault secret. Ordinary
|
||||
runtime startup keeps managed auth first so Codex refresh-token rotation is
|
||||
not rolled back by an older vault snapshot.
|
||||
- API keys and reusable OAuth recovery material are encrypted with Electron
|
||||
`safeStorage` in `credentials/secrets.enc`.
|
||||
- Channel account secrets share the encrypted vault under account-scoped IDs;
|
||||
`credentials/index.json` contains IDs only, never secret values.
|
||||
- Every OpenAI OAuth account owns a complete account-level `CODEX_HOME` under
|
||||
`credentials/oauth/<account-id>/codex-home`; auth files are mode `0600`.
|
||||
- OAuth homes are not symlinked or copied between accounts. Agents may share an
|
||||
account by binding to the same account-level home.
|
||||
- A pre-account shared managed Codex home is moved once to the selected default
|
||||
OAuth account and then removed; it is never copied to a second account.
|
||||
- Runtime profile construction never consumes user-global `~/.codex/auth.json`.
|
||||
That file is inspected only for redacted status and copied only after the user
|
||||
explicitly invokes `importCodexOAuth` for a matching account.
|
||||
- API-key projects receive account-specific environment variables. Secrets are
|
||||
never written literally to generated TOML or exposed to Renderer.
|
||||
- Provider/model/account changes detach the old runtime session and create a
|
||||
new cc-connect/Codex session on the next turn while preserving visible ClawX
|
||||
history.
|
||||
- Missing or incomplete credentials block only bound Agents. Access-token
|
||||
expiry does not invalidate a complete managed OAuth home because
|
||||
cc-connect/Codex owns refresh-token rotation there; a failed refresh is
|
||||
surfaced on that Agent's runtime turn and can be recovered with browser
|
||||
re-login, without changing another Agent's credentials.
|
||||
- Validation may import a complete token set with an expired access or ID token
|
||||
into an isolated managed `CODEX_HOME`. The verifier records only sanitized JWT
|
||||
expiry metadata; only a successful real cc-connect -> Codex turn proves that
|
||||
refresh-token rotation worked. Passing the static precondition alone is not
|
||||
refresh evidence.
|
||||
- Proxy variables are supplied to cc-connect and inherited by its children;
|
||||
localhost, `127.0.0.1`, and `::1` are always added to `NO_PROXY`.
|
||||
|
||||
Initial verified matrix: OpenAI API key, OpenAI Codex OAuth, OpenAI-compatible
|
||||
Responses, and Ollama. Unsupported providers return a stable capability error
|
||||
without mutating OpenClaw config.
|
||||
|
||||
`providers.profile` and `models.profile` are read-only runtime operations. While
|
||||
cc-connect is running they return the ClawX-managed public profile together
|
||||
with each managed project's public Management API `/providers` and `/models`
|
||||
state. They never reuse the sync path and therefore never restart cc-connect.
|
||||
The adapter maps only provider name, active state, model, base URL, model list,
|
||||
and current model; unknown Management fields and secret-like fields never cross
|
||||
the Host API.
|
||||
Provider/model writes remain ClawX-owned: ClawX updates the account-scoped
|
||||
Codex profile and cc-connect project config, then reloads or restarts through
|
||||
the runtime provider.
|
||||
|
||||
## 6. Workspace, skills, and plugins
|
||||
|
||||
New Agents use `~/.clawx/workspaces/agents/<agent-id>`. If an existing OpenClaw
|
||||
Agent has a valid configured workspace, ClawX records that path as
|
||||
`external-openclaw` and reuses it without copying or moving data.
|
||||
|
||||
Each cc-connect project receives exactly that Agent workspace as `work_dir`.
|
||||
No code path may default to `process.cwd()`, the ClawX source checkout, or app
|
||||
resources. Agent deletion removes only `clawx-managed` workspaces.
|
||||
|
||||
When a new Agent requests workspace inheritance, ClawX may read bootstrap files
|
||||
from the existing OpenClaw main workspace, but writes the new Agent under the
|
||||
ClawX-managed root. It never changes or assumes ownership of the source path.
|
||||
|
||||
ClawX owns one Skill Registry. OpenClaw receives its normal skills projection;
|
||||
cc-connect receives the same enabled skills through its project/Codex skills
|
||||
surface. The acceptance test must invoke a real installed skill through chat,
|
||||
not only compare copied files.
|
||||
|
||||
Plugin reuse means shared ClawX capability, account, binding, and UI metadata.
|
||||
OpenClaw JS plugins remain OpenClaw-specific. cc-connect channels are generated
|
||||
as native `projects.platforms` entries and do not load OpenClaw plugins.
|
||||
|
||||
## 7. Chat, events, tools, approvals, and cancellation
|
||||
|
||||
GUI Chat registers as a cc-connect Bridge adapter. cc-connect invokes Codex and
|
||||
emits all run activity over Bridge. The normalized envelope is:
|
||||
|
||||
OpenClaw and cc-connect intentionally use different provider-owned Chat
|
||||
transports behind the same ClawX route. OpenClaw uses the Main-owned ACP
|
||||
session transport introduced by the OpenClaw runtime. cc-connect renders the
|
||||
Runtime Chat implementation and sends through `RuntimeManager` -> active
|
||||
`RuntimeProvider` -> BridgePlatform. Renderer routing follows the active
|
||||
runtime status, not only the pending Settings selection. As defense in depth,
|
||||
Main rejects ACP load, prompt, cancel, and permission requests whenever
|
||||
cc-connect is active; typed media sends remain dispatched through the active
|
||||
runtime provider.
|
||||
|
||||
The adapter follows the pinned cc-connect Web Admin client lifecycle: after
|
||||
`register_ack` it sends a JSON `ping` every 25 seconds, reconnects after 3
|
||||
seconds when the socket drops, and stops both timers during an intentional
|
||||
runtime stop. This is required for scheduler and long-running Agent replies
|
||||
that cross cc-connect's approximately 90-second idle disconnect window.
|
||||
|
||||
```ts
|
||||
interface RuntimeEventEnvelope {
|
||||
schemaVersion: 1
|
||||
eventId: string
|
||||
runtimeKind: RuntimeKind
|
||||
project: string
|
||||
sessionKey: string
|
||||
runtimeSessionId: string
|
||||
runId: string
|
||||
turnId: string
|
||||
seq: number
|
||||
timestamp: string
|
||||
type: RuntimeEventType
|
||||
payload: unknown
|
||||
}
|
||||
```
|
||||
|
||||
Required event types are `run.started`, `assistant.delta`,
|
||||
`reasoning.summary.delta`, `tool.started`, `tool.updated`, `tool.completed`,
|
||||
`command.output`, `patch.completed`, `approval.requested`,
|
||||
`approval.resolved`, `usage.recorded`, and `run.ended`.
|
||||
|
||||
Pinned cc-connect v1.4.1 has two materially different Codex backends. Its
|
||||
default `exec` backend does not map Codex 0.137 `custom_tool_call` records such
|
||||
as `apply_patch` to `EventToolUse`; a real OAuth probe created the requested
|
||||
file while cc-connect reported `tools=0`. ClawX therefore configures every
|
||||
managed Codex project with `backend = "app_server"` and
|
||||
`app_server_url = "stdio://"`. cc-connect remains the process owner and starts
|
||||
the bundled Codex app-server inside the Agent workspace.
|
||||
|
||||
The Bridge adapter registers `progress_style = "card"` and
|
||||
`supports_progress_card_payload = true`. cc-connect then sends the public
|
||||
`__cc_connect_progress_card_v1__:` payload through `preview_start` and
|
||||
`update_message`; ClawX maps typed `thinking`, `tool_use`, `tool_result`, and
|
||||
`error` entries to the shared runtime graph. cc-connect v1.4.1 emits a
|
||||
`fileChange` start but no corresponding result, so a successful or failed final
|
||||
Bridge reply closes any still-open tool with
|
||||
`meta.inferredFromRunCompletion = true`. Explicit tool results always win and
|
||||
are never replaced by the inferred terminal event.
|
||||
|
||||
Plain-text previews use the same normalized `assistant.delta` contract. ClawX
|
||||
emits the initial `preview_start` immediately, applies each `update_message` as
|
||||
an in-place replacement, and clears only that transient assistant text when
|
||||
cc-connect sends `delete_message`. Structured progress is intentionally kept as
|
||||
semantic thinking/tool lifecycle in the execution graph; deleting cc-connect's
|
||||
temporary platform message must not erase the completed tool relationship.
|
||||
|
||||
The opt-in real OAuth E2E proves the full path: GUI send -> RuntimeManager ->
|
||||
cc-connect Bridge -> cc-connect-owned Codex app-server -> Patch -> progress
|
||||
payload -> Main runtime event -> Renderer execution graph. It asserts
|
||||
`transport=stdio`, cc-connect `tools=1`, the managed workspace file, both tool
|
||||
lifecycle events, real approval request/resolution, and the visible graph. It
|
||||
writes sanitized evidence under
|
||||
`artifacts/cc-connect/real-oauth-tool-events.{png,json}` plus
|
||||
`artifacts/cc-connect/real-oauth-approval-request.png`. These screenshots keep
|
||||
the tool type, approval controls, lifecycle state, generated filename, and
|
||||
assistant result visible while masking the isolated managed workspace path.
|
||||
Reading Codex JSONL as a real-time event source, wrapping Codex stdout, or
|
||||
spawning a second Codex bridge remains forbidden. Section 8 documents the sole
|
||||
bounded historical exception for Channel tool packets omitted by public
|
||||
cc-connect history.
|
||||
|
||||
The local-real verifier performs runtime checks with real filesystem paths but
|
||||
replaces repository, home, and temporary roots with `<repo>`, `<home>`, and
|
||||
`<tmp>` before persisting JSON or Markdown. A passing evidence row must not
|
||||
publish a developer's worktree, credential-home, or isolated runtime path.
|
||||
|
||||
Only Codex-provided reasoning summaries are shown. Hidden chain-of-thought is
|
||||
never requested or inferred. `eventId` deduplicates; `runId + seq` orders and
|
||||
detects gaps. Bridge reconnect must replay missing events through
|
||||
cc-connect-owned history once the upstream protocol exposes them. ClawX must
|
||||
not scan Codex transcript files to reconstruct real-time tool activity.
|
||||
|
||||
The app-server backend surfaces approval requests as Bridge `buttons`. ClawX
|
||||
stores the run-correlated `session_key`, `reply_ctx`, project, and only the
|
||||
actions offered by cc-connect. `chat.approval.respond` validates the requested
|
||||
action against that pending set and sends cc-connect's public `card_action`
|
||||
packet; Renderer never talks to Codex and cannot inject an arbitrary action.
|
||||
Deterministic Electron E2E proves request rendering, GUI click, Host API/runtime
|
||||
RPC dispatch, the exact Bridge packet, and resumed assistant delivery. The
|
||||
opt-in real OAuth E2E additionally runs the Main Agent in `suggest` mode and
|
||||
proves the same flow through bundled cc-connect 1.4.1 and bundled Codex: a real
|
||||
Patch approval is rendered, allowed, resolved by cc-connect, and followed by a
|
||||
workspace write and final assistant response.
|
||||
|
||||
The same validated path handles non-approval runtime choices from cc-connect
|
||||
cards. Action rows, list buttons, and select options are parsed from the public
|
||||
card schema; only `perm:`, `askq:`, `cmd:`, `nav:`, and `act:` values are
|
||||
eligible. Select options complete the current Chat run when cc-connect returns
|
||||
the updated state card, while navigation/button cards can continue the same
|
||||
interaction until cc-connect emits a reply or the user aborts. The real bundled
|
||||
`/lang -> card -> card_action -> card` E2E verifies the live language through
|
||||
the public Management project API and preserves the runtime PID. Pinned v1.4.1
|
||||
does not persist manual `/lang` selections to `config.toml`: its save callback
|
||||
is registered only for automatic language detection, so ClawX does not infer a
|
||||
durable write that the runtime did not perform.
|
||||
|
||||
Permission mode is Agent-owned runtime metadata in
|
||||
`~/.clawx/app/agent-bindings.json`, alongside but independent from the Agent's
|
||||
provider-account binding. `full-auto` remains the default; `suggest` selects
|
||||
cc-connect app-server's `on-request` approval policy and read-only sandbox.
|
||||
Saving the mode refreshes the managed project config without writing OpenClaw
|
||||
configuration. Only these two safe product modes are exposed; ClawX does not
|
||||
offer cc-connect's sandbox-bypassing mode.
|
||||
|
||||
Pinned cc-connect v1.4.1 has no dedicated incoming Bridge cancellation packet
|
||||
or per-run cancellation Management endpoint, but its public `/stop` command is
|
||||
session-scoped. `chat.abort` immediately ends the correlated ClawX run, sends
|
||||
`/stop` through BridgePlatform for that session, and suppresses replies correlated
|
||||
to the aborted run. Codex app-server does not implement cc-connect's graceful
|
||||
`CancelTurn` interface, so cc-connect closes only that session's Codex child
|
||||
while preserving its stored AgentSessionID for resume; the cc-connect process
|
||||
and other Agent sessions remain running. If Bridge is disconnected and `/stop`
|
||||
cannot be delivered, ClawX restarts the owned runtime as an explicit fallback.
|
||||
The real local OpenAI-compatible E2E proves upstream stream closure, no late
|
||||
assistant rendering, and an unchanged cc-connect PID.
|
||||
|
||||
## 8. Sessions and history
|
||||
|
||||
Session inventory, ordinary user/assistant history, and deletion use
|
||||
cc-connect's public Management/Bridge session endpoints. ClawX does not read or
|
||||
mutate cc-connect session JSON files. User-assigned titles are ClawX UI metadata
|
||||
stored atomically in `app/cc-connect-session-metadata.json`; deleting a public
|
||||
session deletes its title in the same Host API operation. On first use, labels
|
||||
from the old ClawX-owned `.clawx-supplemental-history.json` are imported without
|
||||
copying its history payload.
|
||||
|
||||
The production Bridge adapter contains no parser for cc-connect session JSON or
|
||||
Codex transcripts. It retains only messages observed on the current public
|
||||
Bridge connection for immediate event delivery; durable list/delete and
|
||||
authoritative ordinary messages always come from the provider's public
|
||||
Management session client.
|
||||
|
||||
Pinned cc-connect can omit historical tool packets from public history for
|
||||
Channel-originated sessions even though Codex recorded and executed those
|
||||
tools. After public history has loaded, the provider may apply one degraded,
|
||||
best-effort compatibility supplement that contributes only tool calls and
|
||||
their results. It cannot create or replace user, assistant, system, attachment,
|
||||
approval, real-time event, or usage records.
|
||||
|
||||
Candidate Codex JSONL files must match the owning Agent workspace. A stale or
|
||||
exact `agent_session_id` does not bypass that check. Fallback discovery is
|
||||
bounded to recent public user-turn text and the timestamp of that exact user
|
||||
record, nearby transcript date directories, bounded path/file caches, and
|
||||
truncated tool output. Missing, stale, cross-workspace, or ambiguous evidence
|
||||
leaves public history unchanged. This exception does not satisfy replacement
|
||||
readiness and must be removed when the pinned cc-connect runtime exposes
|
||||
durable public Channel tool history.
|
||||
|
||||
ClawX owns logical session identity and display metadata; cc-connect owns
|
||||
runtime sessions and message history. Public session responses carry the
|
||||
logical/runtime binding, while `cc-connect-session-metadata.json` stores only
|
||||
optional display labels and never copies runtime credentials or message
|
||||
history.
|
||||
|
||||
cc-connect Session REST/Management APIs are the only production source for
|
||||
list, create, ordinary message history, switch, and delete. The bounded
|
||||
Channel tool supplement above is the only historical content exception. Rename
|
||||
uses an official endpoint if the pinned binary exposes it; otherwise ClawX
|
||||
stores only the display label in its logical index and does not rewrite
|
||||
cc-connect private JSON. Hard delete is reported successful only after the
|
||||
runtime API confirms deletion.
|
||||
|
||||
Runtime or provider switching preserves visible historical turns and detaches
|
||||
the old backend binding. The first subsequent message creates a new runtime
|
||||
session and includes a clearly identified continuation context once. OpenClaw
|
||||
internal session ids are never passed to cc-connect.
|
||||
|
||||
Required cases include active, named, cross-Agent, Channel, Cron, restart,
|
||||
rename, hard delete, and pagination. Session ids must not collide across
|
||||
projects or provider accounts.
|
||||
|
||||
## 9. Token usage
|
||||
|
||||
Usage is a runtime contract, not a dashboard file scan.
|
||||
|
||||
```ts
|
||||
interface RuntimeUsageRecord {
|
||||
id: string
|
||||
runtimeKind: RuntimeKind
|
||||
logicalSessionId: string
|
||||
runtimeSessionId: string
|
||||
turnId: string
|
||||
agentId: string
|
||||
providerAccountId?: string
|
||||
provider: string
|
||||
model: string
|
||||
timestamp: string
|
||||
status: 'available' | 'missing' | 'error'
|
||||
inputTokens: number
|
||||
cachedInputTokens: number
|
||||
outputTokens: number
|
||||
reasoningTokens: number
|
||||
totalTokens: number
|
||||
costUsd?: number
|
||||
}
|
||||
```
|
||||
|
||||
Pinned cc-connect v1.4.1 does not currently expose per-turn token usage through
|
||||
its documented Bridge or Management API, and an actual binary probe confirms
|
||||
that enabling `reply_footer` does not add machine-readable usage to Bridge
|
||||
replies. Therefore this acceptance row is **upstream-blocked**, not complete.
|
||||
Production ClawX derives turn identity only from cc-connect public session
|
||||
history. When that history has no usage payload, each assistant turn is
|
||||
returned with `status: 'missing'` and zero counters so callers can distinguish
|
||||
"the turn exists but usage is unavailable" from "there is no history". ClawX
|
||||
does not fill those counters from private cc-connect stores or Codex JSONL.
|
||||
Test code may use a managed transcript or provider response as an oracle, but
|
||||
that evidence cannot close the exact-usage runtime-contract row.
|
||||
|
||||
`RuntimeProvider.listUsage` is the only Host API usage source. The OpenClaw
|
||||
adapter owns its existing structured transcript scan; the cc-connect adapter
|
||||
owns public Management session/history reads and emits one normalized record
|
||||
per assistant turn. `usage-api` does not call `listSessions`/`loadHistory`
|
||||
itself and does not know either runtime's storage layout. Runtime records carry
|
||||
logical and runtime session ids, a stable turn identity, Agent/provider/model
|
||||
attribution, status, counters, and optional cost/content compatibility fields.
|
||||
|
||||
The upstream audit was refreshed on 2026-07-26. npm still marks `1.4.1` as
|
||||
`latest`; `1.5.0-beta.2` is the newest prerelease. The beta.2 release contains
|
||||
only a Codex model-visibility fix on top of beta.1 and does not publish a usage
|
||||
API. The stable and prerelease source trees parse Codex
|
||||
`thread/tokenUsage/updated` into an internal `ContextUsageReporter`, but the
|
||||
documented Management and Bridge session detail responses still expose only
|
||||
message role/content/timestamp. When context display is enabled, the runtime
|
||||
renders a lossy `[ctx: ~N%]` footer to the platform instead of a structured
|
||||
per-turn payload. ClawX must not parse that display string or reach into
|
||||
cc-connect's internal agent/session state. This is why upgrading to the beta or
|
||||
enabling `reply_footer` does not close the contract.
|
||||
|
||||
Upstream PR [cc-connect#1428](https://github.com/chenhg5/cc-connect/pull/1428)
|
||||
proposes an opt-in Bridge `usage` observer. It is useful directionally, but its
|
||||
current head is conflicting and is not included in stable v1.4.1 or prerelease
|
||||
v1.5.0-beta.2.
|
||||
Its unversioned event contains `session_key`, `turn_id`, input/output/cache
|
||||
counts and user metadata, but omits `project`, provider/model identity,
|
||||
reasoning tokens, durable history semantics and replay after reconnect. Those
|
||||
omissions prevent reliable multi-Agent attribution and historical dashboard
|
||||
reconstruction, so ClawX must not implement production parity against that
|
||||
unmerged schema. A future release may use the observer design provided the
|
||||
published contract addresses these fields or exposes an equivalent durable
|
||||
Management history field.
|
||||
|
||||
Completion requires a pinned cc-connect release to expose a versioned usage
|
||||
event or history field containing project, session/turn, provider/model, and
|
||||
token counts, plus documented reconnect/replay behavior or durable history.
|
||||
ClawX must then map that public payload to `RuntimeUsageRecord`, add a real
|
||||
API-key/OAuth oracle comparison, and remove the checked-in E2E `fixme`.
|
||||
|
||||
`cachedInputTokens` is a subset of input and `reasoningTokens` is a subset of
|
||||
output. If total is absent, calculate `input + output`; never add cache again.
|
||||
Cost is shown only when runtime/provider returns an explicit historical value.
|
||||
Dashboard defaults to the active runtime and offers OpenClaw, cc-connect, and
|
||||
combined filters.
|
||||
|
||||
The shared parser enforces this total rule for both adapters. Public payloads
|
||||
may expose cache-read/cache-write and reasoning counters independently for
|
||||
display, but inferred `totalTokens` remains `inputTokens + outputTokens` so
|
||||
cache and reasoning subsets are never counted twice.
|
||||
|
||||
## 10. Channels and Feishu/Lark
|
||||
|
||||
Channel account metadata lives under `~/.clawx/app`; app secrets live in the
|
||||
encrypted credential vault. Generated cc-connect TOML references environment
|
||||
variables. Connect, disconnect, and delete mean config projection plus
|
||||
Management API reload/status when the pinned binary lacks per-platform
|
||||
lifecycle endpoints.
|
||||
|
||||
Feishu/Lark replacement evidence requires:
|
||||
|
||||
```text
|
||||
tenant message -> cc-connect platform -> bound project/Agent/workspace
|
||||
-> Codex -> cc-connect -> tenant reply
|
||||
```
|
||||
|
||||
Both China Feishu and global Lark domain mappings are tested. Status is read
|
||||
from project platform detail, not inferred from process state. Channel-created
|
||||
sessions must appear in ClawX history and usage under the bound Agent.
|
||||
|
||||
Channel mutations require account-scoped authorization. Runtime hooks may be
|
||||
used as an evidence collector, not as a second message processor.
|
||||
|
||||
Current live-credential evidence proves the Feishu platform reaches
|
||||
`connected`/`running` through cc-connect, survives Host API disconnect/connect
|
||||
reload, preserves both the ClawX desktop administrator and configured Channel
|
||||
administrators, removes the account from managed config on delete, and cleans
|
||||
up the runtime process. The same real test proves an existing OpenClaw channel
|
||||
file is a read-only import source: non-secret account metadata is owned by the
|
||||
canonical runtime config, the app secret is absent from that document and from
|
||||
plaintext vault bytes, and neither import nor cc-connect-mode delete changes
|
||||
the compatibility file. Sanitized machine evidence is written to
|
||||
`artifacts/cc-connect/real-feishu-lifecycle.json`. A tenant-originated inbound
|
||||
marker and its reply remain a separate manual gate; lifecycle success alone
|
||||
does not claim message-delivery parity.
|
||||
|
||||
## 11. Cron
|
||||
|
||||
For the first replacement milestone, cc-connect native cron-expression jobs
|
||||
are the only supported schedule kind. `at`, `every`, and manual run remain
|
||||
explicitly unsupported unless the pinned stable binary exposes equivalent
|
||||
native operations. ClawX must not maintain a second prompt scheduler.
|
||||
|
||||
GUI and Channel `/cron` operate the same cc-connect scheduler and store:
|
||||
|
||||
- Channel create/update/enable/disable/delete is visible in GUI.
|
||||
- GUI mutations are visible through Channel `/cron`.
|
||||
- Scheduled prompt execution returns to the configured Channel through
|
||||
cc-connect.
|
||||
- `admin_from` contains ClawX admins and explicit `cron-manager` role members;
|
||||
other allow-listed users cannot mutate jobs.
|
||||
- Jobs carry project, session key, workspace, schedule, enabled state, and
|
||||
runtime ownership.
|
||||
|
||||
For prompt/exec jobs without external delivery, ClawX uses the managed local
|
||||
LINE placeholder session key because cc-connect Cron resolves the first session
|
||||
key segment as a configured platform. Agent/account/workspace ownership still
|
||||
comes from the job's project. `clawx:<agent>:<session>` remains a Bridge session
|
||||
key and must not be passed to the native scheduler. Announce jobs use the real
|
||||
target platform and recipient key.
|
||||
|
||||
Capability metadata exposes `scheduleKinds: ['cron']`, Channel commands, and
|
||||
the actual support state of manual execution. Unsupported operations are
|
||||
non-mutating.
|
||||
|
||||
cc-connect manual execution is asynchronous: `POST /api/v1/cron/{id}/exec`
|
||||
acknowledges that a run was triggered, but does not mean the run completed.
|
||||
ClawX observes completion through the runtime-owned Cron list and maps the
|
||||
official `last_run` and `last_error` fields to `CronJob.lastRun`; Go's zero
|
||||
timestamp means the job has never run and is not exposed as a completed run.
|
||||
Validation must wait for a successful `lastRun` before using public
|
||||
session/history as delivery evidence.
|
||||
|
||||
The Cron UI keeps trigger acknowledgement non-blocking. After the immediate
|
||||
list refresh, its store observes an unchanged run in the background with a
|
||||
bounded exponential-backoff refresh until `lastRun` changes, the runtime
|
||||
auto-removes the job, the user deletes it, the selected runtime changes, or the
|
||||
job timeout elapses. A repeated trigger supersedes the prior observation. This
|
||||
polling only observes the runtime-owned scheduler; it never executes the job in
|
||||
ClawX.
|
||||
|
||||
Current real-runtime evidence covers both native scheduler paths with the
|
||||
bundled cc-connect binary. An enabled exec job fired on an actual minute tick
|
||||
and wrote its marker from the configured `work_dir`. A Codex OAuth prompt job
|
||||
also fired on an actual minute tick, entered cc-connect through the managed
|
||||
project, and exposed its prompt and assistant reply through the public
|
||||
session-summary/history APIs. The evidence command is
|
||||
`pnpm run verify:cc-connect:local-real:scheduled-cron`; it does not claim live
|
||||
tenant-channel delivery, which remains a separate Feishu/Lark credential gate.
|
||||
Both jobs preserve the cc-connect PID, remain visible through Host API and the
|
||||
Cron page until cleanup, require delete success plus a second Host API list that
|
||||
proves the job is absent, and write sanitized machine/visual evidence to
|
||||
`artifacts/cc-connect/real-scheduled-{exec,prompt}-cron.{json,png}`. The prompt
|
||||
artifact records only public session keys and success flags; it never records
|
||||
OAuth material, Management tokens, or temporary absolute paths.
|
||||
|
||||
The bundled-runtime E2E also registers a simulated Feishu transport through the
|
||||
public Bridge protocol and proves Channel `/cron add`, list, disable, enable,
|
||||
and delete as the projected managed admin are reflected by Host API Cron
|
||||
operations. A GUI-created announce
|
||||
job targeting the same Feishu session is visible from Channel `/cron`, and the
|
||||
runtime PID remains unchanged. Sanitized evidence is written to
|
||||
`artifacts/cc-connect/real-channel-cron-bridge.json`. This verifies cc-connect
|
||||
core/platform command routing and one shared native scheduler; it does not
|
||||
replace live Feishu tenant inbound or scheduled-reply evidence.
|
||||
The probe advertises Bridge `card` and `buttons` capabilities. Pinned
|
||||
cc-connect v1.4.1 returns `/cron add` as a usable text acknowledgement and the
|
||||
`/cron` list as a real card; the test invokes its disable, enable, and delete
|
||||
callbacks through `card_action` and verifies each mutation through Host API.
|
||||
Non-approval standalone-button and upstream-triggered delete-message evidence
|
||||
remain separate from this card/action proof. Preview/update now have an
|
||||
independent local-real proof: the bundled cc-connect v1.4.1 engine runs against
|
||||
a deterministic Codex app-server protocol boundary, emits public
|
||||
`preview_start`/`update_message`, and drives the GUI execution graph plus final
|
||||
assistant reply. Sanitized evidence is written to
|
||||
`artifacts/cc-connect/real-rich-progress-bridge.{json,png}`. This proves the
|
||||
runtime/Bridge/UI integration without claiming a real OpenAI credential; real
|
||||
OAuth remains a separate gate. Real media is covered independently: the bundled
|
||||
`cc-connect send` CLI targets an active managed session and emits public Bridge
|
||||
image/file/audio/video packets. The adapter copies decoded bytes under
|
||||
`runtimes/cc-connect/media/outgoing/bridge`, session history merges these
|
||||
runtime-owned attachments with Management API history, renderer final-event
|
||||
deduplication uses each message id, and Chat keeps `gateway-media` cards visible
|
||||
even when surrounding process narration is folded into the execution graph.
|
||||
The real local OpenAI-compatible E2E verifies exact bytes, image preview, all
|
||||
four GUI cards, and writes sanitized evidence to
|
||||
`artifacts/cc-connect/real-cli-media-bridge.{json,png}`.
|
||||
|
||||
## 12. Health, Doctor, and logs
|
||||
|
||||
Runtime ready requires a live process, Management API, Bridge registration,
|
||||
loaded projects, executable Agent binary, valid required workspace, and scoped
|
||||
credential checks. A single expired Agent account degrades that Agent rather
|
||||
than the whole runtime.
|
||||
|
||||
`checkHealth({ probe: true })` verifies the child is still alive, the Bridge
|
||||
WebSocket is currently registered, and every projected project is readable
|
||||
through Management API. Infrastructure probe failures return `ok: false` with
|
||||
the failed component; account support/auth diagnostics stay project-scoped so
|
||||
one invalid account does not mark unrelated Agents unhealthy.
|
||||
Message preflight resolves the target Agent from the logical session key and
|
||||
checks that project's provider profile. An invalid default account therefore
|
||||
does not block an Agent with a valid explicit binding, and an invalid explicit
|
||||
binding blocks only that Agent before any Bridge message is sent.
|
||||
Agent create, rename, model/account binding, Channel binding, and delete
|
||||
operations notify the active runtime. In cc-connect mode they rebuild or
|
||||
restart cc-connect projects without invoking OpenClaw auth/model projection;
|
||||
OpenClaw keeps its existing projection and reload behavior.
|
||||
Skills are sourced from the shared ClawX/OpenClaw-compatible skill registry and
|
||||
mirrored into every distinct Codex home used by current cc-connect projects.
|
||||
Runtime start, skill enable/disable, and ClawHub install/uninstall all refresh
|
||||
every project home, so account isolation does not split skill availability.
|
||||
|
||||
Startup order is data lock/version, managed config, skills, binary validation,
|
||||
process, Management API, Bridge, projects, health, ready. Intentional stop
|
||||
drains or cancels runs before terminating the process tree. Unexpected crashes
|
||||
use bounded backoff and eventually enter error state.
|
||||
|
||||
Bridge registration is part of startup, not a background best effort. If the
|
||||
process starts but Bridge registration fails, the provider closes registered
|
||||
and in-flight WebSockets, terminates the managed process tree, reports `error`,
|
||||
and leaves no child running. Stop/restart closes sockets that are still waiting
|
||||
for `register_ack` and suppresses any reconnect scheduled by that close.
|
||||
|
||||
Main captures cc-connect stdout/stderr, redacts scoped provider/channel secrets
|
||||
and common bearer/API-key forms before emission, keeps a bounded in-memory tail,
|
||||
and writes mode-0600 `runtimes/cc-connect/logs/runtime.log` with size rotation.
|
||||
Runtime diagnostics combine that stream, matching ClawX manager lines, and a
|
||||
redacted managed config. Renderer never reads the process pipe or log path
|
||||
directly.
|
||||
|
||||
cc-connect Doctor runs native `doctor user-isolation` against managed config
|
||||
with an explicit managed `--out` path, then runs bundled `codex doctor --json`
|
||||
inside the main project's managed `CODEX_HOME`. The adapter writes a
|
||||
mode-0600 composite JSON audit under `runtimes/cc-connect/audits`; it never uses
|
||||
the native default `~/.cc-connect/audits`. A Codex project without
|
||||
`run_as_user` legitimately produces no native user-isolation file, which is
|
||||
recorded as `auditGenerated: false` rather than treated as missing evidence.
|
||||
The Codex Doctor subprocess is a provider-owned diagnostic exception only: it
|
||||
accepts no prompt, creates no chat/session/tool run, and cannot replace or
|
||||
bypass BridgePlatform delivery.
|
||||
`doctor.fix` is unsupported in cc-connect mode and is hidden/disabled.
|
||||
Runtime-neutral Settings strings must not report an OpenClaw Doctor result for
|
||||
cc-connect.
|
||||
|
||||
cc-connect stdout, stderr, structured events, and doctor audits are captured
|
||||
under `~/.clawx/logs/runtimes/cc-connect` with rotation and pre-write secret
|
||||
redaction. Diagnostics use the active provider and must not include OpenClaw
|
||||
gateway logs as cc-connect runtime logs.
|
||||
|
||||
## 13. Migration and rollback
|
||||
|
||||
Migration steps:
|
||||
|
||||
1. Create and lock `~/.clawx` layout.
|
||||
2. Import ClawX application settings and provider accounts from legacy
|
||||
Electron userData.
|
||||
3. Register existing OpenClaw workspaces as external paths.
|
||||
4. Encrypt provider secrets and create account-level OAuth homes.
|
||||
5. Move ClawX-owned cc-connect data from legacy userData into the new runtime
|
||||
directory.
|
||||
6. Build logical session projection without modifying runtime stores.
|
||||
7. Start the selected runtime only after migration commits.
|
||||
|
||||
Rollback means selecting OpenClaw, stopping cc-connect, and preserving its
|
||||
managed data. Rollback never deletes credentials, sessions, workspace, or
|
||||
cc-connect config. A migration failure restores the backup and leaves the prior
|
||||
data version writable by the prior application.
|
||||
|
||||
## 14. Delivery phases and evidence gates
|
||||
|
||||
| Phase | Goal and implementation | Required verification | Impact |
|
||||
| --- | --- | --- | --- |
|
||||
| A. Contract and dependency | Pin/probe stable cc-connect; add runtime contracts and API client | Binary contract test, bundle manifest, type/unit tests | Shared types; no behavior switch |
|
||||
| B. Data root and credentials | Add layout, fail-closed pre-write lock, migrations, encrypted vault, OAuth homes | vN to vN+1 and rollback packaged run; real two-Electron ownership/handover; secret scan | All persistent paths and runtime/scheduler startup |
|
||||
| C. Workspace and skills | Registry, OpenClaw reuse, project `work_dir`, shared skill projection | Two-Agent isolation; real skill invocation; source-checkout negative test | Agent create/delete and files |
|
||||
| D. Bridge chat/events | Official Bridge send, tools, approvals, cancellation, replay | Real API-key and OAuth tool-heavy chats; disconnect/replay; screenshots | Core communication path |
|
||||
| E. Sessions and usage | Official APIs, logical binding, per-turn usage | Named/cross-Agent/Channel/restart/delete; token oracle comparison | Sidebar, history, Models |
|
||||
| F. Channels and cron | Feishu/Lark full path; one native scheduler for GUI and Channel | Tenant inbound/reply; Channel/GUI Cron bidirectional CRUD and scheduled reply | Channel and Cron surfaces |
|
||||
| G. Health and diagnostics | Scoped health, native doctor, real logs | Crash, port conflict, expired auth, doctor audit, log redaction | Settings and diagnostics |
|
||||
| H. Packaging and release | Offline resources and platform smoke | Source bundle integrity; `afterPack` target verification; final macOS x64/arm64, Windows x64, Linux x64/arm64 resource checks; native Electron/Host API/runtime startup, Cron/Doctor, rollback, PID/port/process cleanup | Build/release only |
|
||||
|
||||
Every phase must produce code-level route evidence and actual runtime evidence
|
||||
under `artifacts/cc-connect/<run-id>/`:
|
||||
|
||||
- `api/`: sanitized requests and responses.
|
||||
- `logs/`: ClawX, cc-connect, Bridge, doctor, and scheduler excerpts.
|
||||
- `screenshots/`: ClawX and Channel UI evidence.
|
||||
- `fs/`: sanitized manifests, workspace trees, and migration checks.
|
||||
- `report.json`: acceptance row, command, status, evidence paths, and gaps.
|
||||
|
||||
Mock-only evidence cannot close a real-runtime row. Opt-in credentials may stay
|
||||
outside normal CI, but replacement readiness remains partial until the latest
|
||||
report contains PASS evidence for real OAuth, external OpenAI API key, Feishu
|
||||
inbound/reply, native Channel Cron, and packaged target platforms.
|
||||
|
||||
Deterministic Electron evidence covers same-account browser re-login projection
|
||||
and protects Codex-refreshed managed auth from stale-vault rollback. A live
|
||||
expired-token refresh failure followed by browser re-login still requires an
|
||||
explicit real OAuth fixture and remains an external validation row.
|
||||
|
||||
## 15. Acceptance and explicit non-parity
|
||||
|
||||
cc-connect replacement is complete only when:
|
||||
|
||||
- No cc-connect Chat, session, tool, approval, cancellation, or usage path
|
||||
launches or talks to Codex outside cc-connect.
|
||||
- No shared cc-connect service writes OpenClaw config or cc-connect private
|
||||
session files.
|
||||
- GUI Chat, Feishu/Lark, and native Cron all execute through cc-connect and the
|
||||
bound Agent/account/workspace.
|
||||
- OpenAI OAuth and API-key modes pass real end-to-end tests with account
|
||||
isolation.
|
||||
- Session/history/title/delete and token usage match the shared runtime
|
||||
contract across Agent and Channel cases.
|
||||
- Skills are actually invoked, health/Doctor/logs are runtime-aware, and
|
||||
packaged applications run offline.
|
||||
- Required logs and screenshots exist and sensitive-data scans pass.
|
||||
|
||||
Accepted non-parity for the first milestone:
|
||||
|
||||
- cc-connect remains behind Developer Mode.
|
||||
- cc-connect Doctor Fix does not replace OpenClaw Doctor Fix.
|
||||
- Only native cron expressions are supported; `at` and `every` are not
|
||||
emulated.
|
||||
- Real credential and all-platform release checks remain opt-in until a
|
||||
separate CI policy decision.
|
||||
+21
-3
@@ -38,6 +38,10 @@ afterPack: ./scripts/after-pack.cjs
|
||||
asar: true
|
||||
asarUnpack:
|
||||
- "**/*.node"
|
||||
# lru-cache CJS/ESM interop: older CJS versions (v5, v6, v7) don't export
|
||||
# `LRUCache` as a named property, breaking `import { LRUCache }` in Node.js
|
||||
# 22+ (Electron 40+). Unpacking lets afterPack patch them in place.
|
||||
- "**/node_modules/lru-cache/**"
|
||||
|
||||
# Disable native module rebuilding.
|
||||
# The Electron renderer/main process has no native (.node) dependencies.
|
||||
@@ -64,6 +68,10 @@ mac:
|
||||
to: bin
|
||||
- from: resources/cli/posix/
|
||||
to: cli/
|
||||
- from: build/cc-connect/darwin-${arch}/
|
||||
to: cc-connect/
|
||||
- from: build/codex/darwin-${arch}/
|
||||
to: codex/
|
||||
category: public.app-category.productivity
|
||||
icon: resources/icons/icon.icns
|
||||
target:
|
||||
@@ -86,6 +94,10 @@ mac:
|
||||
NSCameraUsageDescription: ClawX requires camera access for video features
|
||||
|
||||
dmg:
|
||||
# Explicit volume size prevents dmg-builder@1.2.0 auto-calculation from
|
||||
# underestimating (causes "No space left on device" for large app bundles).
|
||||
# The final .dmg is bzip2-compressed, so this only affects the temp volume.
|
||||
size: 2g
|
||||
background: resources/dmg-background.png
|
||||
icon: resources/icons/icon.icns
|
||||
iconSize: 100
|
||||
@@ -111,12 +123,14 @@ win:
|
||||
to: bin
|
||||
- from: resources/cli/win32/
|
||||
to: cli/
|
||||
- from: build/cc-connect/win32-${arch}/
|
||||
to: cc-connect/
|
||||
- from: build/codex/win32-${arch}/
|
||||
to: codex/
|
||||
icon: resources/icons/icon.ico
|
||||
target:
|
||||
- target: nsis
|
||||
arch:
|
||||
- x64
|
||||
- arm64
|
||||
arch: x64
|
||||
|
||||
nsis:
|
||||
oneClick: false
|
||||
@@ -141,6 +155,10 @@ linux:
|
||||
to: bin
|
||||
- from: resources/cli/posix/
|
||||
to: cli/
|
||||
- from: build/cc-connect/linux-${arch}/
|
||||
to: cc-connect/
|
||||
- from: build/codex/linux-${arch}/
|
||||
to: codex/
|
||||
icon: resources/icons
|
||||
target:
|
||||
- target: AppImage
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import type { GatewayManager } from '../gateway/manager';
|
||||
import type { ClawHubService } from '../gateway/clawhub';
|
||||
import type { HostEventBus } from './event-bus';
|
||||
|
||||
export interface HostApiContext {
|
||||
gatewayManager: GatewayManager;
|
||||
clawHubService: ClawHubService;
|
||||
eventBus: HostEventBus;
|
||||
mainWindow: BrowserWindow | null;
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import type { ServerResponse } from 'http';
|
||||
|
||||
type EventPayload = unknown;
|
||||
|
||||
export class HostEventBus {
|
||||
private readonly clients = new Set<ServerResponse>();
|
||||
|
||||
addSseClient(res: ServerResponse): void {
|
||||
this.clients.add(res);
|
||||
res.on('close', () => {
|
||||
this.clients.delete(res);
|
||||
});
|
||||
}
|
||||
|
||||
emit(eventName: string, payload: EventPayload): void {
|
||||
const message = `event: ${eventName}\ndata: ${JSON.stringify(payload)}\n\n`;
|
||||
for (const client of this.clients) {
|
||||
try {
|
||||
client.write(message);
|
||||
} catch {
|
||||
this.clients.delete(client);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
closeAll(): void {
|
||||
for (const client of this.clients) {
|
||||
try {
|
||||
client.end();
|
||||
} catch {
|
||||
// Ignore individual client close failures.
|
||||
}
|
||||
}
|
||||
this.clients.clear();
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
|
||||
export async function parseJsonBody<T>(req: IncomingMessage): Promise<T> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
const raw = Buffer.concat(chunks).toString('utf8').trim();
|
||||
if (!raw) {
|
||||
return {} as T;
|
||||
}
|
||||
return JSON.parse(raw) as T;
|
||||
}
|
||||
|
||||
export function setCorsHeaders(res: ServerResponse): void {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||
}
|
||||
|
||||
export function sendJson(res: ServerResponse, statusCode: number, payload: unknown): void {
|
||||
setCorsHeaders(res);
|
||||
res.statusCode = statusCode;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
export function sendNoContent(res: ServerResponse): void {
|
||||
setCorsHeaders(res);
|
||||
res.statusCode = 204;
|
||||
res.end();
|
||||
}
|
||||
|
||||
export function sendText(res: ServerResponse, statusCode: number, text: string): void {
|
||||
setCorsHeaders(res);
|
||||
res.statusCode = statusCode;
|
||||
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
||||
res.end(text);
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import {
|
||||
assignChannelToAgent,
|
||||
clearChannelBinding,
|
||||
createAgent,
|
||||
deleteAgentConfig,
|
||||
listAgentsSnapshot,
|
||||
resolveAccountIdForAgent,
|
||||
updateAgentName,
|
||||
} from '../../utils/agent-config';
|
||||
import { deleteChannelAccountConfig } from '../../utils/channel-config';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { parseJsonBody, sendJson } from '../route-utils';
|
||||
|
||||
function scheduleGatewayReload(ctx: HostApiContext, reason: string): void {
|
||||
if (ctx.gatewayManager.getStatus().state !== 'stopped') {
|
||||
ctx.gatewayManager.debouncedReload();
|
||||
return;
|
||||
}
|
||||
void reason;
|
||||
}
|
||||
|
||||
export async function handleAgentRoutes(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
ctx: HostApiContext,
|
||||
): Promise<boolean> {
|
||||
if (url.pathname === '/api/agents' && req.method === 'GET') {
|
||||
sendJson(res, 200, { success: true, ...(await listAgentsSnapshot()) });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/agents' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ name: string }>(req);
|
||||
const snapshot = await createAgent(body.name);
|
||||
scheduleGatewayReload(ctx, 'create-agent');
|
||||
sendJson(res, 200, { success: true, ...snapshot });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/agents/') && req.method === 'PUT') {
|
||||
const suffix = url.pathname.slice('/api/agents/'.length);
|
||||
const parts = suffix.split('/').filter(Boolean);
|
||||
|
||||
if (parts.length === 1) {
|
||||
try {
|
||||
const body = await parseJsonBody<{ name: string }>(req);
|
||||
const agentId = decodeURIComponent(parts[0]);
|
||||
const snapshot = await updateAgentName(agentId, body.name);
|
||||
scheduleGatewayReload(ctx, 'update-agent');
|
||||
sendJson(res, 200, { success: true, ...snapshot });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (parts.length === 3 && parts[1] === 'channels') {
|
||||
try {
|
||||
const agentId = decodeURIComponent(parts[0]);
|
||||
const channelType = decodeURIComponent(parts[2]);
|
||||
const snapshot = await assignChannelToAgent(agentId, channelType);
|
||||
scheduleGatewayReload(ctx, 'assign-channel');
|
||||
sendJson(res, 200, { success: true, ...snapshot });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/agents/') && req.method === 'DELETE') {
|
||||
const suffix = url.pathname.slice('/api/agents/'.length);
|
||||
const parts = suffix.split('/').filter(Boolean);
|
||||
|
||||
if (parts.length === 1) {
|
||||
try {
|
||||
const agentId = decodeURIComponent(parts[0]);
|
||||
const snapshot = await deleteAgentConfig(agentId);
|
||||
scheduleGatewayReload(ctx, 'delete-agent');
|
||||
sendJson(res, 200, { success: true, ...snapshot });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (parts.length === 3 && parts[1] === 'channels') {
|
||||
try {
|
||||
const agentId = decodeURIComponent(parts[0]);
|
||||
const channelType = decodeURIComponent(parts[2]);
|
||||
const accountId = resolveAccountIdForAgent(agentId);
|
||||
await deleteChannelAccountConfig(channelType, accountId);
|
||||
const snapshot = await clearChannelBinding(channelType, accountId);
|
||||
scheduleGatewayReload(ctx, 'remove-agent-channel');
|
||||
sendJson(res, 200, { success: true, ...snapshot });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { parseJsonBody } from '../route-utils';
|
||||
import { setCorsHeaders, sendJson, sendNoContent } from '../route-utils';
|
||||
import { runOpenClawDoctor, runOpenClawDoctorFix } from '../../utils/openclaw-doctor';
|
||||
|
||||
export async function handleAppRoutes(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
ctx: HostApiContext,
|
||||
): Promise<boolean> {
|
||||
if (url.pathname === '/api/events' && req.method === 'GET') {
|
||||
setCorsHeaders(res);
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
});
|
||||
res.write(': connected\n\n');
|
||||
ctx.eventBus.addSseClient(res);
|
||||
// Send a current-state snapshot immediately so renderer subscribers do not
|
||||
// miss lifecycle transitions that happened before the SSE connection opened.
|
||||
res.write(`event: gateway:status\ndata: ${JSON.stringify(ctx.gatewayManager.getStatus())}\n\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/app/openclaw-doctor' && req.method === 'POST') {
|
||||
const body = await parseJsonBody<{ mode?: 'diagnose' | 'fix' }>(req);
|
||||
const mode = body.mode === 'fix' ? 'fix' : 'diagnose';
|
||||
sendJson(res, 200, mode === 'fix' ? await runOpenClawDoctorFix() : await runOpenClawDoctor());
|
||||
return true;
|
||||
}
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
sendNoContent(res);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import { app } from 'electron';
|
||||
import { existsSync, cpSync, mkdirSync, rmSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
deleteChannelConfig,
|
||||
getChannelFormValues,
|
||||
listConfiguredChannels,
|
||||
saveChannelConfig,
|
||||
setChannelEnabled,
|
||||
validateChannelConfig,
|
||||
validateChannelCredentials,
|
||||
} from '../../utils/channel-config';
|
||||
import { clearAllBindingsForChannel } from '../../utils/agent-config';
|
||||
import { whatsAppLoginManager } from '../../utils/whatsapp-login';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { parseJsonBody, sendJson } from '../route-utils';
|
||||
|
||||
function scheduleGatewayChannelRestart(ctx: HostApiContext, reason: string): void {
|
||||
if (ctx.gatewayManager.getStatus().state === 'stopped') {
|
||||
return;
|
||||
}
|
||||
ctx.gatewayManager.debouncedRestart();
|
||||
void reason;
|
||||
}
|
||||
|
||||
async function ensureDingTalkPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
|
||||
const targetDir = join(homedir(), '.openclaw', 'extensions', 'dingtalk');
|
||||
const targetManifest = join(targetDir, 'openclaw.plugin.json');
|
||||
|
||||
if (existsSync(targetManifest)) {
|
||||
return { installed: true };
|
||||
}
|
||||
|
||||
const candidateSources = app.isPackaged
|
||||
? [
|
||||
join(process.resourcesPath, 'openclaw-plugins', 'dingtalk'),
|
||||
join(process.resourcesPath, 'app.asar.unpacked', 'build', 'openclaw-plugins', 'dingtalk'),
|
||||
join(process.resourcesPath, 'app.asar.unpacked', 'openclaw-plugins', 'dingtalk'),
|
||||
]
|
||||
: [
|
||||
join(app.getAppPath(), 'build', 'openclaw-plugins', 'dingtalk'),
|
||||
join(process.cwd(), 'build', 'openclaw-plugins', 'dingtalk'),
|
||||
join(__dirname, '../../../build/openclaw-plugins/dingtalk'),
|
||||
];
|
||||
|
||||
const sourceDir = candidateSources.find((dir) => existsSync(join(dir, 'openclaw.plugin.json')));
|
||||
if (!sourceDir) {
|
||||
return {
|
||||
installed: false,
|
||||
warning: `Bundled DingTalk plugin mirror not found. Checked: ${candidateSources.join(' | ')}`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
mkdirSync(join(homedir(), '.openclaw', 'extensions'), { recursive: true });
|
||||
rmSync(targetDir, { recursive: true, force: true });
|
||||
cpSync(sourceDir, targetDir, { recursive: true, dereference: true });
|
||||
if (!existsSync(targetManifest)) {
|
||||
return { installed: false, warning: 'Failed to install DingTalk plugin mirror (manifest missing).' };
|
||||
}
|
||||
return { installed: true };
|
||||
} catch {
|
||||
return { installed: false, warning: 'Failed to install bundled DingTalk plugin mirror' };
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureWeComPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
|
||||
const targetDir = join(homedir(), '.openclaw', 'extensions', 'wecom');
|
||||
const targetManifest = join(targetDir, 'openclaw.plugin.json');
|
||||
|
||||
if (existsSync(targetManifest)) {
|
||||
return { installed: true };
|
||||
}
|
||||
|
||||
const candidateSources = app.isPackaged
|
||||
? [
|
||||
join(process.resourcesPath, 'openclaw-plugins', 'wecom'),
|
||||
join(process.resourcesPath, 'app.asar.unpacked', 'build', 'openclaw-plugins', 'wecom'),
|
||||
join(process.resourcesPath, 'app.asar.unpacked', 'openclaw-plugins', 'wecom'),
|
||||
]
|
||||
: [
|
||||
join(app.getAppPath(), 'build', 'openclaw-plugins', 'wecom'),
|
||||
join(process.cwd(), 'build', 'openclaw-plugins', 'wecom'),
|
||||
join(__dirname, '../../../build/openclaw-plugins/wecom'),
|
||||
];
|
||||
|
||||
const sourceDir = candidateSources.find((dir) => existsSync(join(dir, 'openclaw.plugin.json')));
|
||||
if (!sourceDir) {
|
||||
return {
|
||||
installed: false,
|
||||
warning: `Bundled WeCom plugin mirror not found. Checked: ${candidateSources.join(' | ')}`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
mkdirSync(join(homedir(), '.openclaw', 'extensions'), { recursive: true });
|
||||
rmSync(targetDir, { recursive: true, force: true });
|
||||
cpSync(sourceDir, targetDir, { recursive: true, dereference: true });
|
||||
if (!existsSync(targetManifest)) {
|
||||
return { installed: false, warning: 'Failed to install WeCom plugin mirror (manifest missing).' };
|
||||
}
|
||||
return { installed: true };
|
||||
} catch {
|
||||
return { installed: false, warning: 'Failed to install bundled WeCom plugin mirror' };
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureFeishuPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
|
||||
const targetDir = join(homedir(), '.openclaw', 'extensions', 'feishu-openclaw-plugin');
|
||||
const targetManifest = join(targetDir, 'openclaw.plugin.json');
|
||||
|
||||
if (existsSync(targetManifest)) {
|
||||
return { installed: true };
|
||||
}
|
||||
|
||||
const candidateSources = app.isPackaged
|
||||
? [
|
||||
join(process.resourcesPath, 'openclaw-plugins', 'feishu-openclaw-plugin'),
|
||||
join(process.resourcesPath, 'app.asar.unpacked', 'build', 'openclaw-plugins', 'feishu-openclaw-plugin'),
|
||||
join(process.resourcesPath, 'app.asar.unpacked', 'openclaw-plugins', 'feishu-openclaw-plugin'),
|
||||
]
|
||||
: [
|
||||
join(app.getAppPath(), 'build', 'openclaw-plugins', 'feishu-openclaw-plugin'),
|
||||
join(process.cwd(), 'build', 'openclaw-plugins', 'feishu-openclaw-plugin'),
|
||||
join(__dirname, '../../../build/openclaw-plugins/feishu-openclaw-plugin'),
|
||||
];
|
||||
|
||||
const sourceDir = candidateSources.find((dir) => existsSync(join(dir, 'openclaw.plugin.json')));
|
||||
if (!sourceDir) {
|
||||
return {
|
||||
installed: false,
|
||||
warning: `Bundled Feishu plugin mirror not found. Checked: ${candidateSources.join(' | ')}`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
mkdirSync(join(homedir(), '.openclaw', 'extensions'), { recursive: true });
|
||||
rmSync(targetDir, { recursive: true, force: true });
|
||||
cpSync(sourceDir, targetDir, { recursive: true, dereference: true });
|
||||
if (!existsSync(targetManifest)) {
|
||||
return { installed: false, warning: 'Failed to install Feishu plugin mirror (manifest missing).' };
|
||||
}
|
||||
return { installed: true };
|
||||
} catch {
|
||||
return { installed: false, warning: 'Failed to install bundled Feishu plugin mirror' };
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureQQBotPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
|
||||
const targetDir = join(homedir(), '.openclaw', 'extensions', 'qqbot');
|
||||
const targetManifest = join(targetDir, 'openclaw.plugin.json');
|
||||
|
||||
if (existsSync(targetManifest)) {
|
||||
return { installed: true };
|
||||
}
|
||||
|
||||
const candidateSources = app.isPackaged
|
||||
? [
|
||||
join(process.resourcesPath, 'openclaw-plugins', 'qqbot'),
|
||||
join(process.resourcesPath, 'app.asar.unpacked', 'build', 'openclaw-plugins', 'qqbot'),
|
||||
join(process.resourcesPath, 'app.asar.unpacked', 'openclaw-plugins', 'qqbot'),
|
||||
]
|
||||
: [
|
||||
join(app.getAppPath(), 'build', 'openclaw-plugins', 'qqbot'),
|
||||
join(process.cwd(), 'build', 'openclaw-plugins', 'qqbot'),
|
||||
join(__dirname, '../../../build/openclaw-plugins/qqbot'),
|
||||
];
|
||||
|
||||
const sourceDir = candidateSources.find((dir) => existsSync(join(dir, 'openclaw.plugin.json')));
|
||||
if (!sourceDir) {
|
||||
return {
|
||||
installed: false,
|
||||
warning: `Bundled QQ Bot plugin mirror not found. Checked: ${candidateSources.join(' | ')}`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
mkdirSync(join(homedir(), '.openclaw', 'extensions'), { recursive: true });
|
||||
rmSync(targetDir, { recursive: true, force: true });
|
||||
cpSync(sourceDir, targetDir, { recursive: true, dereference: true });
|
||||
if (!existsSync(targetManifest)) {
|
||||
return { installed: false, warning: 'Failed to install QQ Bot plugin mirror (manifest missing).' };
|
||||
}
|
||||
return { installed: true };
|
||||
} catch {
|
||||
return { installed: false, warning: 'Failed to install bundled QQ Bot plugin mirror' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleChannelRoutes(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
ctx: HostApiContext,
|
||||
): Promise<boolean> {
|
||||
if (url.pathname === '/api/channels/configured' && req.method === 'GET') {
|
||||
sendJson(res, 200, { success: true, channels: await listConfiguredChannels() });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/channels/config/validate' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ channelType: string }>(req);
|
||||
sendJson(res, 200, { success: true, ...(await validateChannelConfig(body.channelType)) });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, valid: false, errors: [String(error)], warnings: [] });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/channels/credentials/validate' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ channelType: string; config: Record<string, string> }>(req);
|
||||
sendJson(res, 200, { success: true, ...(await validateChannelCredentials(body.channelType, body.config)) });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, valid: false, errors: [String(error)], warnings: [] });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/channels/whatsapp/start' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ accountId: string }>(req);
|
||||
await whatsAppLoginManager.start(body.accountId);
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/channels/whatsapp/cancel' && req.method === 'POST') {
|
||||
try {
|
||||
await whatsAppLoginManager.stop();
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/channels/config' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ channelType: string; config: Record<string, unknown>; accountId?: string }>(req);
|
||||
if (body.channelType === 'dingtalk') {
|
||||
const installResult = await ensureDingTalkPluginInstalled();
|
||||
if (!installResult.installed) {
|
||||
sendJson(res, 500, { success: false, error: installResult.warning || 'DingTalk plugin install failed' });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (body.channelType === 'wecom') {
|
||||
const installResult = await ensureWeComPluginInstalled();
|
||||
if (!installResult.installed) {
|
||||
sendJson(res, 500, { success: false, error: installResult.warning || 'WeCom plugin install failed' });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (body.channelType === 'qqbot') {
|
||||
const installResult = await ensureQQBotPluginInstalled();
|
||||
if (!installResult.installed) {
|
||||
sendJson(res, 500, { success: false, error: installResult.warning || 'QQ Bot plugin install failed' });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (body.channelType === 'feishu') {
|
||||
const installResult = await ensureFeishuPluginInstalled();
|
||||
if (!installResult.installed) {
|
||||
sendJson(res, 500, { success: false, error: installResult.warning || 'Feishu plugin install failed' });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
await saveChannelConfig(body.channelType, body.config, body.accountId);
|
||||
scheduleGatewayChannelRestart(ctx, `channel:saveConfig:${body.channelType}`);
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/channels/config/enabled' && req.method === 'PUT') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ channelType: string; enabled: boolean }>(req);
|
||||
await setChannelEnabled(body.channelType, body.enabled);
|
||||
scheduleGatewayChannelRestart(ctx, `channel:setEnabled:${body.channelType}`);
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/channels/config/') && req.method === 'GET') {
|
||||
try {
|
||||
const channelType = decodeURIComponent(url.pathname.slice('/api/channels/config/'.length));
|
||||
const accountId = url.searchParams.get('accountId') || undefined;
|
||||
sendJson(res, 200, {
|
||||
success: true,
|
||||
values: await getChannelFormValues(channelType, accountId),
|
||||
});
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/channels/config/') && req.method === 'DELETE') {
|
||||
try {
|
||||
const channelType = decodeURIComponent(url.pathname.slice('/api/channels/config/'.length));
|
||||
await deleteChannelConfig(channelType);
|
||||
await clearAllBindingsForChannel(channelType);
|
||||
scheduleGatewayChannelRestart(ctx, `channel:deleteConfig:${channelType}`);
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void ctx;
|
||||
return false;
|
||||
}
|
||||
@@ -1,448 +0,0 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import { join } from 'node:path';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { parseJsonBody, sendJson } from '../route-utils';
|
||||
import { getOpenClawConfigDir } from '../../utils/paths';
|
||||
|
||||
interface GatewayCronJob {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
enabled: boolean;
|
||||
createdAtMs: number;
|
||||
updatedAtMs: number;
|
||||
schedule: { kind: string; expr?: string; everyMs?: number; at?: string; tz?: string };
|
||||
payload: { kind: string; message?: string; text?: string };
|
||||
delivery?: { mode: string; channel?: string; to?: string };
|
||||
sessionTarget?: string;
|
||||
state: {
|
||||
nextRunAtMs?: number;
|
||||
runningAtMs?: number;
|
||||
lastRunAtMs?: number;
|
||||
lastStatus?: string;
|
||||
lastError?: string;
|
||||
lastDurationMs?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface CronRunLogEntry {
|
||||
jobId?: string;
|
||||
action?: string;
|
||||
status?: string;
|
||||
error?: string;
|
||||
summary?: string;
|
||||
sessionId?: string;
|
||||
sessionKey?: string;
|
||||
ts?: number;
|
||||
runAtMs?: number;
|
||||
durationMs?: number;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
}
|
||||
|
||||
interface CronSessionKeyParts {
|
||||
agentId: string;
|
||||
jobId: string;
|
||||
runSessionId?: string;
|
||||
}
|
||||
|
||||
interface CronSessionFallbackMessage {
|
||||
id: string;
|
||||
role: 'assistant' | 'system';
|
||||
content: string;
|
||||
timestamp: number;
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
function parseCronSessionKey(sessionKey: string): CronSessionKeyParts | null {
|
||||
if (!sessionKey.startsWith('agent:')) return null;
|
||||
const parts = sessionKey.split(':');
|
||||
if (parts.length < 4 || parts[2] !== 'cron') return null;
|
||||
|
||||
const agentId = parts[1] || 'main';
|
||||
const jobId = parts[3];
|
||||
if (!jobId) return null;
|
||||
|
||||
if (parts.length === 4) {
|
||||
return { agentId, jobId };
|
||||
}
|
||||
|
||||
if (parts.length === 6 && parts[4] === 'run' && parts[5]) {
|
||||
return { agentId, jobId, runSessionId: parts[5] };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeTimestampMs(value: unknown): number | undefined {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value < 1e12 ? value * 1000 : value;
|
||||
}
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsed = Date.parse(value);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function formatDuration(durationMs: number | undefined): string | null {
|
||||
if (!durationMs || !Number.isFinite(durationMs)) return null;
|
||||
if (durationMs < 1000) return `${Math.round(durationMs)}ms`;
|
||||
if (durationMs < 10_000) return `${(durationMs / 1000).toFixed(1)}s`;
|
||||
return `${Math.round(durationMs / 1000)}s`;
|
||||
}
|
||||
|
||||
function buildCronRunMessage(entry: CronRunLogEntry, index: number): CronSessionFallbackMessage | null {
|
||||
const timestamp = normalizeTimestampMs(entry.ts) ?? normalizeTimestampMs(entry.runAtMs);
|
||||
if (!timestamp) return null;
|
||||
|
||||
const status = typeof entry.status === 'string' ? entry.status.toLowerCase() : '';
|
||||
const summary = typeof entry.summary === 'string' ? entry.summary.trim() : '';
|
||||
const error = typeof entry.error === 'string' ? entry.error.trim() : '';
|
||||
let content = summary || error;
|
||||
|
||||
if (!content) {
|
||||
content = status === 'error'
|
||||
? 'Scheduled task failed.'
|
||||
: 'Scheduled task completed.';
|
||||
}
|
||||
|
||||
if (status === 'error' && !content.toLowerCase().startsWith('run failed:')) {
|
||||
content = `Run failed: ${content}`;
|
||||
}
|
||||
|
||||
const meta: string[] = [];
|
||||
const duration = formatDuration(entry.durationMs);
|
||||
if (duration) meta.push(`Duration: ${duration}`);
|
||||
if (entry.provider && entry.model) {
|
||||
meta.push(`Model: ${entry.provider}/${entry.model}`);
|
||||
} else if (entry.model) {
|
||||
meta.push(`Model: ${entry.model}`);
|
||||
}
|
||||
if (meta.length > 0) {
|
||||
content = `${content}\n\n${meta.join(' | ')}`;
|
||||
}
|
||||
|
||||
return {
|
||||
id: `cron-run-${entry.sessionId ?? entry.ts ?? index}`,
|
||||
role: status === 'error' ? 'system' : 'assistant',
|
||||
content,
|
||||
timestamp,
|
||||
...(status === 'error' ? { isError: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function readCronRunLog(jobId: string): Promise<CronRunLogEntry[]> {
|
||||
const logPath = join(getOpenClawConfigDir(), 'cron', 'runs', `${jobId}.jsonl`);
|
||||
const raw = await readFile(logPath, 'utf8').catch(() => '');
|
||||
if (!raw.trim()) return [];
|
||||
|
||||
const entries: CronRunLogEntry[] = [];
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
const entry = JSON.parse(trimmed) as CronRunLogEntry;
|
||||
if (!entry || entry.jobId !== jobId) continue;
|
||||
if (entry.action && entry.action !== 'finished') continue;
|
||||
entries.push(entry);
|
||||
} catch {
|
||||
// Ignore malformed log lines so one bad entry does not hide the rest.
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function readSessionStoreEntry(
|
||||
agentId: string,
|
||||
sessionKey: string,
|
||||
): Promise<Record<string, unknown> | undefined> {
|
||||
const storePath = join(getOpenClawConfigDir(), 'agents', agentId, 'sessions', 'sessions.json');
|
||||
const raw = await readFile(storePath, 'utf8').catch(() => '');
|
||||
if (!raw.trim()) return undefined;
|
||||
|
||||
try {
|
||||
const store = JSON.parse(raw) as Record<string, unknown>;
|
||||
const directEntry = store[sessionKey];
|
||||
if (directEntry && typeof directEntry === 'object') {
|
||||
return directEntry as Record<string, unknown>;
|
||||
}
|
||||
|
||||
const sessions = (store as { sessions?: unknown }).sessions;
|
||||
if (Array.isArray(sessions)) {
|
||||
const arrayEntry = sessions.find((entry) => {
|
||||
if (!entry || typeof entry !== 'object') return false;
|
||||
const record = entry as Record<string, unknown>;
|
||||
return record.key === sessionKey || record.sessionKey === sessionKey;
|
||||
});
|
||||
if (arrayEntry && typeof arrayEntry === 'object') {
|
||||
return arrayEntry as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function buildCronSessionFallbackMessages(params: {
|
||||
sessionKey: string;
|
||||
job?: Pick<GatewayCronJob, 'name' | 'payload' | 'state'>;
|
||||
runs: CronRunLogEntry[];
|
||||
sessionEntry?: { label?: string; updatedAt?: number };
|
||||
limit?: number;
|
||||
}): CronSessionFallbackMessage[] {
|
||||
const parsed = parseCronSessionKey(params.sessionKey);
|
||||
if (!parsed) return [];
|
||||
|
||||
const matchingRuns = params.runs
|
||||
.filter((entry) => {
|
||||
if (!parsed.runSessionId) return true;
|
||||
return entry.sessionId === parsed.runSessionId
|
||||
|| entry.sessionKey === `${params.sessionKey}`;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const left = normalizeTimestampMs(a.ts) ?? normalizeTimestampMs(a.runAtMs) ?? 0;
|
||||
const right = normalizeTimestampMs(b.ts) ?? normalizeTimestampMs(b.runAtMs) ?? 0;
|
||||
return left - right;
|
||||
});
|
||||
|
||||
const messages: CronSessionFallbackMessage[] = [];
|
||||
const prompt = params.job?.payload?.message || params.job?.payload?.text || '';
|
||||
const taskName = params.job?.name?.trim()
|
||||
|| params.sessionEntry?.label?.replace(/^Cron:\s*/, '').trim()
|
||||
|| '';
|
||||
const firstRelevantTimestamp = matchingRuns.length > 0
|
||||
? (normalizeTimestampMs(matchingRuns[0]?.runAtMs) ?? normalizeTimestampMs(matchingRuns[0]?.ts))
|
||||
: (normalizeTimestampMs(params.job?.state?.runningAtMs) ?? params.sessionEntry?.updatedAt);
|
||||
|
||||
if (taskName || prompt) {
|
||||
const lines = [taskName ? `Scheduled task: ${taskName}` : 'Scheduled task'];
|
||||
if (prompt) lines.push(`Prompt: ${prompt}`);
|
||||
messages.push({
|
||||
id: `cron-meta-${parsed.jobId}`,
|
||||
role: 'system',
|
||||
content: lines.join('\n'),
|
||||
timestamp: Math.max(0, (firstRelevantTimestamp ?? Date.now()) - 1),
|
||||
});
|
||||
}
|
||||
|
||||
matchingRuns.forEach((entry, index) => {
|
||||
const message = buildCronRunMessage(entry, index);
|
||||
if (message) messages.push(message);
|
||||
});
|
||||
|
||||
if (matchingRuns.length === 0) {
|
||||
const runningAt = normalizeTimestampMs(params.job?.state?.runningAtMs);
|
||||
if (runningAt) {
|
||||
messages.push({
|
||||
id: `cron-running-${parsed.jobId}`,
|
||||
role: 'system',
|
||||
content: 'This scheduled task is still running in OpenClaw, but no chat transcript is available yet.',
|
||||
timestamp: runningAt,
|
||||
});
|
||||
} else if (messages.length === 0) {
|
||||
messages.push({
|
||||
id: `cron-empty-${parsed.jobId}`,
|
||||
role: 'system',
|
||||
content: 'No chat transcript is available for this scheduled task yet.',
|
||||
timestamp: params.sessionEntry?.updatedAt ?? Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const limit = typeof params.limit === 'number' && Number.isFinite(params.limit)
|
||||
? Math.max(1, Math.floor(params.limit))
|
||||
: messages.length;
|
||||
return messages.slice(-limit);
|
||||
}
|
||||
|
||||
function transformCronJob(job: GatewayCronJob) {
|
||||
const message = job.payload?.message || job.payload?.text || '';
|
||||
const channelType = job.delivery?.channel;
|
||||
const target = channelType
|
||||
? { channelType, channelId: channelType, channelName: channelType }
|
||||
: undefined;
|
||||
const lastRun = job.state?.lastRunAtMs
|
||||
? {
|
||||
time: new Date(job.state.lastRunAtMs).toISOString(),
|
||||
success: job.state.lastStatus === 'ok',
|
||||
error: job.state.lastError,
|
||||
duration: job.state.lastDurationMs,
|
||||
}
|
||||
: undefined;
|
||||
const nextRun = job.state?.nextRunAtMs
|
||||
? new Date(job.state.nextRunAtMs).toISOString()
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
id: job.id,
|
||||
name: job.name,
|
||||
message,
|
||||
schedule: job.schedule,
|
||||
target,
|
||||
enabled: job.enabled,
|
||||
createdAt: new Date(job.createdAtMs).toISOString(),
|
||||
updatedAt: new Date(job.updatedAtMs).toISOString(),
|
||||
lastRun,
|
||||
nextRun,
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleCronRoutes(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
ctx: HostApiContext,
|
||||
): Promise<boolean> {
|
||||
if (url.pathname === '/api/cron/session-history' && req.method === 'GET') {
|
||||
const sessionKey = url.searchParams.get('sessionKey')?.trim() || '';
|
||||
const parsedSession = parseCronSessionKey(sessionKey);
|
||||
if (!parsedSession) {
|
||||
sendJson(res, 400, { success: false, error: `Invalid cron sessionKey: ${sessionKey}` });
|
||||
return true;
|
||||
}
|
||||
|
||||
const rawLimit = Number(url.searchParams.get('limit') || '200');
|
||||
const limit = Number.isFinite(rawLimit)
|
||||
? Math.min(Math.max(Math.floor(rawLimit), 1), 200)
|
||||
: 200;
|
||||
|
||||
try {
|
||||
const [jobsResult, runs, sessionEntry] = await Promise.all([
|
||||
ctx.gatewayManager.rpc('cron.list', { includeDisabled: true })
|
||||
.catch(() => ({ jobs: [] as GatewayCronJob[] })),
|
||||
readCronRunLog(parsedSession.jobId),
|
||||
readSessionStoreEntry(parsedSession.agentId, sessionKey),
|
||||
]);
|
||||
|
||||
const jobs = (jobsResult as { jobs?: GatewayCronJob[] }).jobs ?? [];
|
||||
const job = jobs.find((item) => item.id === parsedSession.jobId);
|
||||
const messages = buildCronSessionFallbackMessages({
|
||||
sessionKey,
|
||||
job,
|
||||
runs,
|
||||
sessionEntry: sessionEntry ? {
|
||||
label: typeof sessionEntry.label === 'string' ? sessionEntry.label : undefined,
|
||||
updatedAt: normalizeTimestampMs(sessionEntry.updatedAt),
|
||||
} : undefined,
|
||||
limit,
|
||||
});
|
||||
|
||||
sendJson(res, 200, { messages });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/cron/jobs' && req.method === 'GET') {
|
||||
try {
|
||||
const result = await ctx.gatewayManager.rpc('cron.list', { includeDisabled: true });
|
||||
const data = result as { jobs?: GatewayCronJob[] };
|
||||
const jobs = data?.jobs ?? [];
|
||||
for (const job of jobs) {
|
||||
const isIsolatedAgent =
|
||||
(job.sessionTarget === 'isolated' || !job.sessionTarget) &&
|
||||
job.payload?.kind === 'agentTurn';
|
||||
const needsRepair =
|
||||
isIsolatedAgent &&
|
||||
job.delivery?.mode === 'announce' &&
|
||||
!job.delivery?.channel;
|
||||
if (needsRepair) {
|
||||
try {
|
||||
await ctx.gatewayManager.rpc('cron.update', {
|
||||
id: job.id,
|
||||
patch: { delivery: { mode: 'none' } },
|
||||
});
|
||||
job.delivery = { mode: 'none' };
|
||||
if (job.state?.lastError?.includes('Channel is required')) {
|
||||
job.state.lastError = undefined;
|
||||
job.state.lastStatus = 'ok';
|
||||
}
|
||||
} catch {
|
||||
// ignore per-job repair failure
|
||||
}
|
||||
}
|
||||
}
|
||||
sendJson(res, 200, jobs.map(transformCronJob));
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/cron/jobs' && req.method === 'POST') {
|
||||
try {
|
||||
const input = await parseJsonBody<{ name: string; message: string; schedule: string; enabled?: boolean }>(req);
|
||||
const result = await ctx.gatewayManager.rpc('cron.add', {
|
||||
name: input.name,
|
||||
schedule: { kind: 'cron', expr: input.schedule },
|
||||
payload: { kind: 'agentTurn', message: input.message },
|
||||
enabled: input.enabled ?? true,
|
||||
wakeMode: 'next-heartbeat',
|
||||
sessionTarget: 'isolated',
|
||||
delivery: { mode: 'none' },
|
||||
});
|
||||
sendJson(res, 200, result && typeof result === 'object' ? transformCronJob(result as GatewayCronJob) : result);
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/cron/jobs/') && req.method === 'PUT') {
|
||||
try {
|
||||
const id = decodeURIComponent(url.pathname.slice('/api/cron/jobs/'.length));
|
||||
const input = await parseJsonBody<Record<string, unknown>>(req);
|
||||
const patch = { ...input };
|
||||
if (typeof patch.schedule === 'string') {
|
||||
patch.schedule = { kind: 'cron', expr: patch.schedule };
|
||||
}
|
||||
if (typeof patch.message === 'string') {
|
||||
patch.payload = { kind: 'agentTurn', message: patch.message };
|
||||
delete patch.message;
|
||||
}
|
||||
sendJson(res, 200, await ctx.gatewayManager.rpc('cron.update', { id, patch }));
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/cron/jobs/') && req.method === 'DELETE') {
|
||||
try {
|
||||
const id = decodeURIComponent(url.pathname.slice('/api/cron/jobs/'.length));
|
||||
sendJson(res, 200, await ctx.gatewayManager.rpc('cron.remove', { id }));
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/cron/toggle' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ id: string; enabled: boolean }>(req);
|
||||
sendJson(res, 200, await ctx.gatewayManager.rpc('cron.update', { id: body.id, patch: { enabled: body.enabled } }));
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/cron/trigger' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ id: string }>(req);
|
||||
sendJson(res, 200, await ctx.gatewayManager.rpc('cron.run', { id: body.id, mode: 'force' }));
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import { dialog, nativeImage } from 'electron';
|
||||
import crypto from 'node:crypto';
|
||||
import { extname, join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { parseJsonBody, sendJson } from '../route-utils';
|
||||
|
||||
const EXT_MIME_MAP: Record<string, string> = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.bmp': 'image/bmp',
|
||||
'.ico': 'image/x-icon',
|
||||
'.mp4': 'video/mp4',
|
||||
'.webm': 'video/webm',
|
||||
'.mov': 'video/quicktime',
|
||||
'.avi': 'video/x-msvideo',
|
||||
'.mkv': 'video/x-matroska',
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.wav': 'audio/wav',
|
||||
'.ogg': 'audio/ogg',
|
||||
'.flac': 'audio/flac',
|
||||
'.pdf': 'application/pdf',
|
||||
'.zip': 'application/zip',
|
||||
'.gz': 'application/gzip',
|
||||
'.tar': 'application/x-tar',
|
||||
'.7z': 'application/x-7z-compressed',
|
||||
'.rar': 'application/vnd.rar',
|
||||
'.json': 'application/json',
|
||||
'.xml': 'application/xml',
|
||||
'.csv': 'text/csv',
|
||||
'.txt': 'text/plain',
|
||||
'.md': 'text/markdown',
|
||||
'.html': 'text/html',
|
||||
'.css': 'text/css',
|
||||
'.js': 'text/javascript',
|
||||
'.ts': 'text/typescript',
|
||||
'.py': 'text/x-python',
|
||||
};
|
||||
|
||||
function getMimeType(ext: string): string {
|
||||
return EXT_MIME_MAP[ext.toLowerCase()] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
function mimeToExt(mimeType: string): string {
|
||||
for (const [ext, mime] of Object.entries(EXT_MIME_MAP)) {
|
||||
if (mime === mimeType) return ext;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
const OUTBOUND_DIR = join(homedir(), '.openclaw', 'media', 'outbound');
|
||||
|
||||
async function generateImagePreview(filePath: string, mimeType: string): Promise<string | null> {
|
||||
try {
|
||||
const img = nativeImage.createFromPath(filePath);
|
||||
if (img.isEmpty()) return null;
|
||||
const size = img.getSize();
|
||||
const maxDim = 512;
|
||||
if (size.width > maxDim || size.height > maxDim) {
|
||||
const resized = size.width >= size.height
|
||||
? img.resize({ width: maxDim })
|
||||
: img.resize({ height: maxDim });
|
||||
return `data:image/png;base64,${resized.toPNG().toString('base64')}`;
|
||||
}
|
||||
const { readFile } = await import('node:fs/promises');
|
||||
const buf = await readFile(filePath);
|
||||
return `data:${mimeType};base64,${buf.toString('base64')}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleFileRoutes(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
_ctx: HostApiContext,
|
||||
): Promise<boolean> {
|
||||
if (url.pathname === '/api/files/stage-paths' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ filePaths: string[] }>(req);
|
||||
const fsP = await import('node:fs/promises');
|
||||
await fsP.mkdir(OUTBOUND_DIR, { recursive: true });
|
||||
const results = [];
|
||||
for (const filePath of body.filePaths) {
|
||||
const id = crypto.randomUUID();
|
||||
const ext = extname(filePath);
|
||||
const stagedPath = join(OUTBOUND_DIR, `${id}${ext}`);
|
||||
await fsP.copyFile(filePath, stagedPath);
|
||||
const s = await fsP.stat(stagedPath);
|
||||
const mimeType = getMimeType(ext);
|
||||
const fileName = filePath.split(/[\\/]/).pop() || 'file';
|
||||
const preview = mimeType.startsWith('image/')
|
||||
? await generateImagePreview(stagedPath, mimeType)
|
||||
: null;
|
||||
results.push({ id, fileName, mimeType, fileSize: s.size, stagedPath, preview });
|
||||
}
|
||||
sendJson(res, 200, results);
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/files/stage-buffer' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ base64: string; fileName: string; mimeType: string }>(req);
|
||||
const fsP = await import('node:fs/promises');
|
||||
await fsP.mkdir(OUTBOUND_DIR, { recursive: true });
|
||||
const id = crypto.randomUUID();
|
||||
const ext = extname(body.fileName) || mimeToExt(body.mimeType);
|
||||
const stagedPath = join(OUTBOUND_DIR, `${id}${ext}`);
|
||||
const buffer = Buffer.from(body.base64, 'base64');
|
||||
await fsP.writeFile(stagedPath, buffer);
|
||||
const mimeType = body.mimeType || getMimeType(ext);
|
||||
const preview = mimeType.startsWith('image/')
|
||||
? await generateImagePreview(stagedPath, mimeType)
|
||||
: null;
|
||||
sendJson(res, 200, {
|
||||
id,
|
||||
fileName: body.fileName,
|
||||
mimeType,
|
||||
fileSize: buffer.length,
|
||||
stagedPath,
|
||||
preview,
|
||||
});
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/files/thumbnails' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ paths: Array<{ filePath: string; mimeType: string }> }>(req);
|
||||
const fsP = await import('node:fs/promises');
|
||||
const results: Record<string, { preview: string | null; fileSize: number }> = {};
|
||||
for (const { filePath, mimeType } of body.paths) {
|
||||
try {
|
||||
const s = await fsP.stat(filePath);
|
||||
const preview = mimeType.startsWith('image/')
|
||||
? await generateImagePreview(filePath, mimeType)
|
||||
: null;
|
||||
results[filePath] = { preview, fileSize: s.size };
|
||||
} catch {
|
||||
results[filePath] = { preview: null, fileSize: 0 };
|
||||
}
|
||||
}
|
||||
sendJson(res, 200, results);
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/files/save-image' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<{
|
||||
base64?: string;
|
||||
mimeType?: string;
|
||||
filePath?: string;
|
||||
defaultFileName: string;
|
||||
}>(req);
|
||||
const ext = body.defaultFileName.includes('.')
|
||||
? body.defaultFileName.split('.').pop()!
|
||||
: (body.mimeType?.split('/')[1] || 'png');
|
||||
const result = await dialog.showSaveDialog({
|
||||
defaultPath: join(homedir(), 'Downloads', body.defaultFileName),
|
||||
filters: [
|
||||
{ name: 'Images', extensions: [ext, 'png', 'jpg', 'jpeg', 'webp', 'gif'] },
|
||||
{ name: 'All Files', extensions: ['*'] },
|
||||
],
|
||||
});
|
||||
if (result.canceled || !result.filePath) {
|
||||
sendJson(res, 200, { success: false });
|
||||
return true;
|
||||
}
|
||||
const fsP = await import('node:fs/promises');
|
||||
if (body.filePath) {
|
||||
await fsP.copyFile(body.filePath, result.filePath);
|
||||
} else if (body.base64) {
|
||||
await fsP.writeFile(result.filePath, Buffer.from(body.base64, 'base64'));
|
||||
} else {
|
||||
sendJson(res, 400, { success: false, error: 'No image data provided' });
|
||||
return true;
|
||||
}
|
||||
sendJson(res, 200, { success: true, savedPath: result.filePath });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import { PORTS } from '../../utils/config';
|
||||
import { getSetting } from '../../utils/store';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { parseJsonBody, sendJson } from '../route-utils';
|
||||
|
||||
export async function handleGatewayRoutes(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
ctx: HostApiContext,
|
||||
): Promise<boolean> {
|
||||
if (url.pathname === '/api/app/gateway-info' && req.method === 'GET') {
|
||||
const status = ctx.gatewayManager.getStatus();
|
||||
const token = await getSetting('gatewayToken');
|
||||
const port = status.port || PORTS.OPENCLAW_GATEWAY;
|
||||
sendJson(res, 200, {
|
||||
wsUrl: `ws://127.0.0.1:${port}/ws`,
|
||||
token,
|
||||
port,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/gateway/status' && req.method === 'GET') {
|
||||
sendJson(res, 200, ctx.gatewayManager.getStatus());
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/gateway/health' && req.method === 'GET') {
|
||||
const health = await ctx.gatewayManager.checkHealth();
|
||||
sendJson(res, 200, health);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/gateway/start' && req.method === 'POST') {
|
||||
try {
|
||||
await ctx.gatewayManager.start();
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/gateway/stop' && req.method === 'POST') {
|
||||
try {
|
||||
await ctx.gatewayManager.stop();
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/gateway/restart' && req.method === 'POST') {
|
||||
try {
|
||||
await ctx.gatewayManager.restart();
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/gateway/control-ui' && req.method === 'GET') {
|
||||
try {
|
||||
const status = ctx.gatewayManager.getStatus();
|
||||
const token = await getSetting('gatewayToken');
|
||||
const port = status.port || PORTS.OPENCLAW_GATEWAY;
|
||||
const urlValue = `http://127.0.0.1:${port}/?token=${encodeURIComponent(token)}`;
|
||||
sendJson(res, 200, { success: true, url: urlValue, token, port });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/chat/send-with-media' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<{
|
||||
sessionKey: string;
|
||||
message: string;
|
||||
deliver?: boolean;
|
||||
idempotencyKey: string;
|
||||
media?: Array<{ filePath: string; mimeType: string; fileName: string }>;
|
||||
}>(req);
|
||||
const VISION_MIME_TYPES = new Set([
|
||||
'image/png', 'image/jpeg', 'image/bmp', 'image/webp',
|
||||
]);
|
||||
const imageAttachments: Array<{ content: string; mimeType: string; fileName: string }> = [];
|
||||
const fileReferences: string[] = [];
|
||||
if (body.media && body.media.length > 0) {
|
||||
const fsP = await import('node:fs/promises');
|
||||
for (const m of body.media) {
|
||||
fileReferences.push(`[media attached: ${m.filePath} (${m.mimeType}) | ${m.filePath}]`);
|
||||
if (VISION_MIME_TYPES.has(m.mimeType)) {
|
||||
const fileBuffer = await fsP.readFile(m.filePath);
|
||||
imageAttachments.push({
|
||||
content: fileBuffer.toString('base64'),
|
||||
mimeType: m.mimeType,
|
||||
fileName: m.fileName,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const message = fileReferences.length > 0
|
||||
? [body.message, ...fileReferences].filter(Boolean).join('\n')
|
||||
: body.message;
|
||||
const rpcParams: Record<string, unknown> = {
|
||||
sessionKey: body.sessionKey,
|
||||
message,
|
||||
deliver: body.deliver ?? false,
|
||||
idempotencyKey: body.idempotencyKey,
|
||||
};
|
||||
if (imageAttachments.length > 0) {
|
||||
rpcParams.attachments = imageAttachments;
|
||||
}
|
||||
const result = await ctx.gatewayManager.rpc('chat.send', rpcParams, 120000);
|
||||
sendJson(res, 200, { success: true, result });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import { logger } from '../../utils/logger';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { sendJson } from '../route-utils';
|
||||
|
||||
export async function handleLogRoutes(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
_ctx: HostApiContext,
|
||||
): Promise<boolean> {
|
||||
if (url.pathname === '/api/logs' && req.method === 'GET') {
|
||||
const tailLines = Number(url.searchParams.get('tailLines') || '100');
|
||||
sendJson(res, 200, { content: await logger.readLogFile(Number.isFinite(tailLines) ? tailLines : 100) });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/logs/dir' && req.method === 'GET') {
|
||||
sendJson(res, 200, { dir: logger.getLogDir() });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/logs/files' && req.method === 'GET') {
|
||||
sendJson(res, 200, { files: await logger.listLogFiles() });
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -1,324 +0,0 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import {
|
||||
type ProviderConfig,
|
||||
} from '../../utils/secure-storage';
|
||||
import {
|
||||
getProviderConfig,
|
||||
} from '../../utils/provider-registry';
|
||||
import { deviceOAuthManager, type OAuthProviderType } from '../../utils/device-oauth';
|
||||
import { browserOAuthManager, type BrowserOAuthProviderType } from '../../utils/browser-oauth';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { parseJsonBody, sendJson } from '../route-utils';
|
||||
import {
|
||||
syncDefaultProviderToRuntime,
|
||||
syncDeletedProviderApiKeyToRuntime,
|
||||
syncDeletedProviderToRuntime,
|
||||
syncProviderApiKeyToRuntime,
|
||||
syncSavedProviderToRuntime,
|
||||
syncUpdatedProviderToRuntime,
|
||||
} from '../../services/providers/provider-runtime-sync';
|
||||
import { validateApiKeyWithProvider } from '../../services/providers/provider-validation';
|
||||
import { getProviderService } from '../../services/providers/provider-service';
|
||||
import { providerAccountToConfig } from '../../services/providers/provider-store';
|
||||
import type { ProviderAccount } from '../../shared/providers/types';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
const legacyProviderRoutesWarned = new Set<string>();
|
||||
|
||||
export async function handleProviderRoutes(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
ctx: HostApiContext,
|
||||
): Promise<boolean> {
|
||||
const providerService = getProviderService();
|
||||
const logLegacyProviderRoute = (route: string): void => {
|
||||
if (legacyProviderRoutesWarned.has(route)) return;
|
||||
legacyProviderRoutesWarned.add(route);
|
||||
logger.warn(
|
||||
`[provider-migration] Legacy HTTP route "${route}" is deprecated. Prefer /api/provider-accounts endpoints.`,
|
||||
);
|
||||
};
|
||||
|
||||
if (url.pathname === '/api/provider-vendors' && req.method === 'GET') {
|
||||
sendJson(res, 200, await providerService.listVendors());
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/provider-accounts' && req.method === 'GET') {
|
||||
sendJson(res, 200, await providerService.listAccounts());
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/provider-accounts' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ account: ProviderAccount; apiKey?: string }>(req);
|
||||
const account = await providerService.createAccount(body.account, body.apiKey);
|
||||
await syncSavedProviderToRuntime(providerAccountToConfig(account), body.apiKey, ctx.gatewayManager);
|
||||
sendJson(res, 200, { success: true, account });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/provider-accounts/default' && req.method === 'GET') {
|
||||
sendJson(res, 200, { accountId: await providerService.getDefaultAccountId() ?? null });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/provider-accounts/default' && req.method === 'PUT') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ accountId: string }>(req);
|
||||
await providerService.setDefaultAccount(body.accountId);
|
||||
await syncDefaultProviderToRuntime(body.accountId, ctx.gatewayManager);
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/provider-accounts/') && req.method === 'GET') {
|
||||
const accountId = decodeURIComponent(url.pathname.slice('/api/provider-accounts/'.length));
|
||||
sendJson(res, 200, await providerService.getAccount(accountId));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/provider-accounts/') && req.method === 'PUT') {
|
||||
const accountId = decodeURIComponent(url.pathname.slice('/api/provider-accounts/'.length));
|
||||
try {
|
||||
const body = await parseJsonBody<{ updates: Partial<ProviderAccount>; apiKey?: string }>(req);
|
||||
const existing = await providerService.getAccount(accountId);
|
||||
if (!existing) {
|
||||
sendJson(res, 404, { success: false, error: 'Provider account not found' });
|
||||
return true;
|
||||
}
|
||||
const nextAccount = await providerService.updateAccount(accountId, body.updates, body.apiKey);
|
||||
await syncUpdatedProviderToRuntime(providerAccountToConfig(nextAccount), body.apiKey, ctx.gatewayManager);
|
||||
sendJson(res, 200, { success: true, account: nextAccount });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/provider-accounts/') && req.method === 'DELETE') {
|
||||
const accountId = decodeURIComponent(url.pathname.slice('/api/provider-accounts/'.length));
|
||||
try {
|
||||
const existing = await providerService.getAccount(accountId);
|
||||
const runtimeProviderKey = existing?.authMode === 'oauth_browser'
|
||||
? (existing.vendorId === 'google'
|
||||
? 'google-gemini-cli'
|
||||
: (existing.vendorId === 'openai' ? 'openai-codex' : undefined))
|
||||
: undefined;
|
||||
if (url.searchParams.get('apiKeyOnly') === '1') {
|
||||
await providerService.deleteLegacyProviderApiKey(accountId);
|
||||
await syncDeletedProviderApiKeyToRuntime(
|
||||
existing ? providerAccountToConfig(existing) : null,
|
||||
accountId,
|
||||
runtimeProviderKey,
|
||||
);
|
||||
sendJson(res, 200, { success: true });
|
||||
return true;
|
||||
}
|
||||
await providerService.deleteAccount(accountId);
|
||||
await syncDeletedProviderToRuntime(
|
||||
existing ? providerAccountToConfig(existing) : null,
|
||||
accountId,
|
||||
ctx.gatewayManager,
|
||||
runtimeProviderKey,
|
||||
);
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/providers' && req.method === 'GET') {
|
||||
logLegacyProviderRoute('GET /api/providers');
|
||||
sendJson(res, 200, await providerService.listLegacyProvidersWithKeyInfo());
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/providers/default' && req.method === 'GET') {
|
||||
logLegacyProviderRoute('GET /api/providers/default');
|
||||
sendJson(res, 200, { providerId: await providerService.getDefaultLegacyProvider() ?? null });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/providers/default' && req.method === 'PUT') {
|
||||
logLegacyProviderRoute('PUT /api/providers/default');
|
||||
try {
|
||||
const body = await parseJsonBody<{ providerId: string }>(req);
|
||||
await providerService.setDefaultLegacyProvider(body.providerId);
|
||||
await syncDefaultProviderToRuntime(body.providerId, ctx.gatewayManager);
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/providers/validate' && req.method === 'POST') {
|
||||
logLegacyProviderRoute('POST /api/providers/validate');
|
||||
try {
|
||||
const body = await parseJsonBody<{ providerId: string; apiKey: string; options?: { baseUrl?: string; apiProtocol?: string } }>(req);
|
||||
const provider = await providerService.getLegacyProvider(body.providerId);
|
||||
const providerType = provider?.type || body.providerId;
|
||||
const registryBaseUrl = getProviderConfig(providerType)?.baseUrl;
|
||||
const resolvedBaseUrl = body.options?.baseUrl || provider?.baseUrl || registryBaseUrl;
|
||||
const resolvedProtocol = body.options?.apiProtocol || provider?.apiProtocol;
|
||||
sendJson(res, 200, await validateApiKeyWithProvider(providerType, body.apiKey, { baseUrl: resolvedBaseUrl, apiProtocol: resolvedProtocol as any }));
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { valid: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/providers/oauth/start' && req.method === 'POST') {
|
||||
logLegacyProviderRoute('POST /api/providers/oauth/start');
|
||||
try {
|
||||
const body = await parseJsonBody<{
|
||||
provider: OAuthProviderType | BrowserOAuthProviderType;
|
||||
region?: 'global' | 'cn';
|
||||
accountId?: string;
|
||||
label?: string;
|
||||
}>(req);
|
||||
if (body.provider === 'google' || body.provider === 'openai') {
|
||||
await browserOAuthManager.startFlow(body.provider, {
|
||||
accountId: body.accountId,
|
||||
label: body.label,
|
||||
});
|
||||
} else {
|
||||
await deviceOAuthManager.startFlow(body.provider, body.region, {
|
||||
accountId: body.accountId,
|
||||
label: body.label,
|
||||
});
|
||||
}
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/providers/oauth/cancel' && req.method === 'POST') {
|
||||
logLegacyProviderRoute('POST /api/providers/oauth/cancel');
|
||||
try {
|
||||
await deviceOAuthManager.stopFlow();
|
||||
await browserOAuthManager.stopFlow();
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/providers/oauth/submit' && req.method === 'POST') {
|
||||
logLegacyProviderRoute('POST /api/providers/oauth/submit');
|
||||
try {
|
||||
const body = await parseJsonBody<{ code: string }>(req);
|
||||
const accepted = browserOAuthManager.submitManualCode(body.code || '');
|
||||
if (!accepted) {
|
||||
sendJson(res, 400, { success: false, error: 'No active manual OAuth input pending' });
|
||||
return true;
|
||||
}
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/providers' && req.method === 'POST') {
|
||||
logLegacyProviderRoute('POST /api/providers');
|
||||
try {
|
||||
const body = await parseJsonBody<{ config: ProviderConfig; apiKey?: string }>(req);
|
||||
const config = body.config;
|
||||
await providerService.saveLegacyProvider(config);
|
||||
if (body.apiKey !== undefined) {
|
||||
const trimmedKey = body.apiKey.trim();
|
||||
if (trimmedKey) {
|
||||
await providerService.setLegacyProviderApiKey(config.id, trimmedKey);
|
||||
await syncProviderApiKeyToRuntime(config.type, config.id, trimmedKey);
|
||||
}
|
||||
}
|
||||
await syncSavedProviderToRuntime(config, body.apiKey, ctx.gatewayManager);
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/providers/') && req.method === 'GET') {
|
||||
logLegacyProviderRoute('GET /api/providers/:id');
|
||||
const providerId = decodeURIComponent(url.pathname.slice('/api/providers/'.length));
|
||||
if (providerId.endsWith('/api-key')) {
|
||||
const actualId = providerId.slice(0, -('/api-key'.length));
|
||||
sendJson(res, 200, { apiKey: await providerService.getLegacyProviderApiKey(actualId) });
|
||||
return true;
|
||||
}
|
||||
if (providerId.endsWith('/has-api-key')) {
|
||||
const actualId = providerId.slice(0, -('/has-api-key'.length));
|
||||
sendJson(res, 200, { hasKey: await providerService.hasLegacyProviderApiKey(actualId) });
|
||||
return true;
|
||||
}
|
||||
sendJson(res, 200, await providerService.getLegacyProvider(providerId));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/providers/') && req.method === 'PUT') {
|
||||
logLegacyProviderRoute('PUT /api/providers/:id');
|
||||
const providerId = decodeURIComponent(url.pathname.slice('/api/providers/'.length));
|
||||
try {
|
||||
const body = await parseJsonBody<{ updates: Partial<ProviderConfig>; apiKey?: string }>(req);
|
||||
const existing = await providerService.getLegacyProvider(providerId);
|
||||
if (!existing) {
|
||||
sendJson(res, 404, { success: false, error: 'Provider not found' });
|
||||
return true;
|
||||
}
|
||||
const nextConfig: ProviderConfig = { ...existing, ...body.updates, updatedAt: new Date().toISOString() };
|
||||
await providerService.saveLegacyProvider(nextConfig);
|
||||
if (body.apiKey !== undefined) {
|
||||
const trimmedKey = body.apiKey.trim();
|
||||
if (trimmedKey) {
|
||||
await providerService.setLegacyProviderApiKey(providerId, trimmedKey);
|
||||
await syncProviderApiKeyToRuntime(nextConfig.type, providerId, trimmedKey);
|
||||
} else {
|
||||
await providerService.deleteLegacyProviderApiKey(providerId);
|
||||
await syncDeletedProviderApiKeyToRuntime(existing, providerId);
|
||||
}
|
||||
}
|
||||
await syncUpdatedProviderToRuntime(nextConfig, body.apiKey, ctx.gatewayManager);
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/providers/') && req.method === 'DELETE') {
|
||||
logLegacyProviderRoute('DELETE /api/providers/:id');
|
||||
const providerId = decodeURIComponent(url.pathname.slice('/api/providers/'.length));
|
||||
try {
|
||||
const existing = await providerService.getLegacyProvider(providerId);
|
||||
if (url.searchParams.get('apiKeyOnly') === '1') {
|
||||
await providerService.deleteLegacyProviderApiKey(providerId);
|
||||
await syncDeletedProviderApiKeyToRuntime(existing, providerId);
|
||||
sendJson(res, 200, { success: true });
|
||||
return true;
|
||||
}
|
||||
await providerService.deleteLegacyProvider(providerId);
|
||||
await syncDeletedProviderToRuntime(existing, providerId, ctx.gatewayManager);
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import { join } from 'node:path';
|
||||
import { getOpenClawConfigDir } from '../../utils/paths';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { parseJsonBody, sendJson } from '../route-utils';
|
||||
|
||||
export async function handleSessionRoutes(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
_ctx: HostApiContext,
|
||||
): Promise<boolean> {
|
||||
if (url.pathname === '/api/sessions/delete' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ sessionKey: string }>(req);
|
||||
const sessionKey = body.sessionKey;
|
||||
if (!sessionKey || !sessionKey.startsWith('agent:')) {
|
||||
sendJson(res, 400, { success: false, error: `Invalid sessionKey: ${sessionKey}` });
|
||||
return true;
|
||||
}
|
||||
const parts = sessionKey.split(':');
|
||||
if (parts.length < 3) {
|
||||
sendJson(res, 400, { success: false, error: `sessionKey has too few parts: ${sessionKey}` });
|
||||
return true;
|
||||
}
|
||||
const agentId = parts[1];
|
||||
const sessionsDir = join(getOpenClawConfigDir(), 'agents', agentId, 'sessions');
|
||||
const sessionsJsonPath = join(sessionsDir, 'sessions.json');
|
||||
const fsP = await import('node:fs/promises');
|
||||
const raw = await fsP.readFile(sessionsJsonPath, 'utf8');
|
||||
const sessionsJson = JSON.parse(raw) as Record<string, unknown>;
|
||||
|
||||
let uuidFileName: string | undefined;
|
||||
let resolvedSrcPath: string | undefined;
|
||||
if (Array.isArray(sessionsJson.sessions)) {
|
||||
const entry = (sessionsJson.sessions as Array<Record<string, unknown>>)
|
||||
.find((s) => s.key === sessionKey || s.sessionKey === sessionKey);
|
||||
if (entry) {
|
||||
uuidFileName = (entry.file ?? entry.fileName ?? entry.path) as string | undefined;
|
||||
if (!uuidFileName && typeof entry.id === 'string') {
|
||||
uuidFileName = `${entry.id}.jsonl`;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!uuidFileName && sessionsJson[sessionKey] != null) {
|
||||
const val = sessionsJson[sessionKey];
|
||||
if (typeof val === 'string') {
|
||||
uuidFileName = val;
|
||||
} else if (typeof val === 'object' && val !== null) {
|
||||
const entry = val as Record<string, unknown>;
|
||||
const absFile = (entry.sessionFile ?? entry.file ?? entry.fileName ?? entry.path) as string | undefined;
|
||||
if (absFile) {
|
||||
if (absFile.startsWith('/') || absFile.match(/^[A-Za-z]:\\/)) {
|
||||
resolvedSrcPath = absFile;
|
||||
} else {
|
||||
uuidFileName = absFile;
|
||||
}
|
||||
} else {
|
||||
const uuidVal = (entry.id ?? entry.sessionId) as string | undefined;
|
||||
if (uuidVal) uuidFileName = uuidVal.endsWith('.jsonl') ? uuidVal : `${uuidVal}.jsonl`;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!uuidFileName && !resolvedSrcPath) {
|
||||
sendJson(res, 404, { success: false, error: `Cannot resolve file for session: ${sessionKey}` });
|
||||
return true;
|
||||
}
|
||||
if (!resolvedSrcPath) {
|
||||
if (!uuidFileName!.endsWith('.jsonl')) uuidFileName = `${uuidFileName}.jsonl`;
|
||||
resolvedSrcPath = join(sessionsDir, uuidFileName!);
|
||||
}
|
||||
const dstPath = resolvedSrcPath.replace(/\.jsonl$/, '.deleted.jsonl');
|
||||
try {
|
||||
await fsP.access(resolvedSrcPath);
|
||||
await fsP.rename(resolvedSrcPath, dstPath);
|
||||
} catch {
|
||||
// Non-fatal; still try to update sessions.json.
|
||||
}
|
||||
const raw2 = await fsP.readFile(sessionsJsonPath, 'utf8');
|
||||
const json2 = JSON.parse(raw2) as Record<string, unknown>;
|
||||
if (Array.isArray(json2.sessions)) {
|
||||
json2.sessions = (json2.sessions as Array<Record<string, unknown>>)
|
||||
.filter((s) => s.key !== sessionKey && s.sessionKey !== sessionKey);
|
||||
} else if (json2[sessionKey]) {
|
||||
delete json2[sessionKey];
|
||||
}
|
||||
await fsP.writeFile(sessionsJsonPath, JSON.stringify(json2, null, 2), 'utf8');
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import { applyProxySettings } from '../../main/proxy';
|
||||
import { syncLaunchAtStartupSettingFromStore } from '../../main/launch-at-startup';
|
||||
import { getAllSettings, getSetting, resetSettings, setSetting, type AppSettings } from '../../utils/store';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { parseJsonBody, sendJson } from '../route-utils';
|
||||
|
||||
async function handleProxySettingsChange(ctx: HostApiContext): Promise<void> {
|
||||
const settings = await getAllSettings();
|
||||
await applyProxySettings(settings);
|
||||
if (ctx.gatewayManager.getStatus().state === 'running') {
|
||||
await ctx.gatewayManager.restart();
|
||||
}
|
||||
}
|
||||
|
||||
function patchTouchesProxy(patch: Partial<AppSettings>): boolean {
|
||||
return Object.keys(patch).some((key) => (
|
||||
key === 'proxyEnabled' ||
|
||||
key === 'proxyServer' ||
|
||||
key === 'proxyHttpServer' ||
|
||||
key === 'proxyHttpsServer' ||
|
||||
key === 'proxyAllServer' ||
|
||||
key === 'proxyBypassRules'
|
||||
));
|
||||
}
|
||||
|
||||
function patchTouchesLaunchAtStartup(patch: Partial<AppSettings>): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(patch, 'launchAtStartup');
|
||||
}
|
||||
|
||||
export async function handleSettingsRoutes(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
ctx: HostApiContext,
|
||||
): Promise<boolean> {
|
||||
if (url.pathname === '/api/settings' && req.method === 'GET') {
|
||||
sendJson(res, 200, await getAllSettings());
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/settings' && req.method === 'PUT') {
|
||||
try {
|
||||
const patch = await parseJsonBody<Partial<AppSettings>>(req);
|
||||
const entries = Object.entries(patch) as Array<[keyof AppSettings, AppSettings[keyof AppSettings]]>;
|
||||
for (const [key, value] of entries) {
|
||||
await setSetting(key, value);
|
||||
}
|
||||
if (patchTouchesProxy(patch)) {
|
||||
await handleProxySettingsChange(ctx);
|
||||
}
|
||||
if (patchTouchesLaunchAtStartup(patch)) {
|
||||
await syncLaunchAtStartupSettingFromStore();
|
||||
}
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/settings/') && req.method === 'GET') {
|
||||
const key = url.pathname.slice('/api/settings/'.length) as keyof AppSettings;
|
||||
try {
|
||||
sendJson(res, 200, { value: await getSetting(key) });
|
||||
} catch (error) {
|
||||
sendJson(res, 404, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/settings/') && req.method === 'PUT') {
|
||||
const key = url.pathname.slice('/api/settings/'.length) as keyof AppSettings;
|
||||
try {
|
||||
const body = await parseJsonBody<{ value: AppSettings[keyof AppSettings] }>(req);
|
||||
await setSetting(key, body.value);
|
||||
if (
|
||||
key === 'proxyEnabled' ||
|
||||
key === 'proxyServer' ||
|
||||
key === 'proxyHttpServer' ||
|
||||
key === 'proxyHttpsServer' ||
|
||||
key === 'proxyAllServer' ||
|
||||
key === 'proxyBypassRules'
|
||||
) {
|
||||
await handleProxySettingsChange(ctx);
|
||||
}
|
||||
if (key === 'launchAtStartup') {
|
||||
await syncLaunchAtStartupSettingFromStore();
|
||||
}
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/settings/reset' && req.method === 'POST') {
|
||||
try {
|
||||
await resetSettings();
|
||||
await handleProxySettingsChange(ctx);
|
||||
await syncLaunchAtStartupSettingFromStore();
|
||||
sendJson(res, 200, { success: true, settings: await getAllSettings() });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import { getAllSkillConfigs, updateSkillConfig } from '../../utils/skill-config';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { parseJsonBody, sendJson } from '../route-utils';
|
||||
|
||||
export async function handleSkillRoutes(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
ctx: HostApiContext,
|
||||
): Promise<boolean> {
|
||||
if (url.pathname === '/api/skills/configs' && req.method === 'GET') {
|
||||
sendJson(res, 200, await getAllSkillConfigs());
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/skills/config' && req.method === 'PUT') {
|
||||
try {
|
||||
const body = await parseJsonBody<{
|
||||
skillKey: string;
|
||||
apiKey?: string;
|
||||
env?: Record<string, string>;
|
||||
}>(req);
|
||||
sendJson(res, 200, await updateSkillConfig(body.skillKey, {
|
||||
apiKey: body.apiKey,
|
||||
env: body.env,
|
||||
}));
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/clawhub/search' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<Record<string, unknown>>(req);
|
||||
sendJson(res, 200, {
|
||||
success: true,
|
||||
results: await ctx.clawHubService.search(body),
|
||||
});
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/clawhub/install' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<Record<string, unknown>>(req);
|
||||
await ctx.clawHubService.install(body);
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/clawhub/uninstall' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<Record<string, unknown>>(req);
|
||||
await ctx.clawHubService.uninstall(body);
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/clawhub/list' && req.method === 'GET') {
|
||||
try {
|
||||
sendJson(res, 200, { success: true, results: await ctx.clawHubService.listInstalled() });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/clawhub/open-readme' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ slug?: string; skillKey?: string; baseDir?: string }>(req);
|
||||
await ctx.clawHubService.openSkillReadme(body.skillKey || body.slug || '', body.slug, body.baseDir);
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/clawhub/open-path' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ slug?: string; skillKey?: string; baseDir?: string }>(req);
|
||||
await ctx.clawHubService.openSkillPath(body.skillKey || body.slug || '', body.slug, body.baseDir);
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import { getRecentTokenUsageHistory } from '../../utils/token-usage';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { sendJson } from '../route-utils';
|
||||
|
||||
export async function handleUsageRoutes(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
_ctx: HostApiContext,
|
||||
): Promise<boolean> {
|
||||
if (url.pathname === '/api/usage/recent-token-history' && req.method === 'GET') {
|
||||
const rawLimit = url.searchParams.get('limit');
|
||||
let limit: number | undefined;
|
||||
if (rawLimit != null && rawLimit.trim() !== '') {
|
||||
const parsedLimit = Number(rawLimit);
|
||||
if (Number.isFinite(parsedLimit)) {
|
||||
limit = Math.max(Math.floor(parsedLimit), 1);
|
||||
}
|
||||
}
|
||||
sendJson(res, 200, await getRecentTokenUsageHistory(limit));
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
||||
import { PORTS } from '../utils/config';
|
||||
import { logger } from '../utils/logger';
|
||||
import type { HostApiContext } from './context';
|
||||
import { handleAppRoutes } from './routes/app';
|
||||
import { handleGatewayRoutes } from './routes/gateway';
|
||||
import { handleSettingsRoutes } from './routes/settings';
|
||||
import { handleProviderRoutes } from './routes/providers';
|
||||
import { handleAgentRoutes } from './routes/agents';
|
||||
import { handleChannelRoutes } from './routes/channels';
|
||||
import { handleLogRoutes } from './routes/logs';
|
||||
import { handleUsageRoutes } from './routes/usage';
|
||||
import { handleSkillRoutes } from './routes/skills';
|
||||
import { handleFileRoutes } from './routes/files';
|
||||
import { handleSessionRoutes } from './routes/sessions';
|
||||
import { handleCronRoutes } from './routes/cron';
|
||||
import { sendJson } from './route-utils';
|
||||
|
||||
type RouteHandler = (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
ctx: HostApiContext,
|
||||
) => Promise<boolean>;
|
||||
|
||||
const routeHandlers: RouteHandler[] = [
|
||||
handleAppRoutes,
|
||||
handleGatewayRoutes,
|
||||
handleSettingsRoutes,
|
||||
handleProviderRoutes,
|
||||
handleAgentRoutes,
|
||||
handleChannelRoutes,
|
||||
handleSkillRoutes,
|
||||
handleFileRoutes,
|
||||
handleSessionRoutes,
|
||||
handleCronRoutes,
|
||||
handleLogRoutes,
|
||||
handleUsageRoutes,
|
||||
];
|
||||
|
||||
export function startHostApiServer(ctx: HostApiContext, port = PORTS.CLAWX_HOST_API): Server {
|
||||
const server = createServer(async (req, res) => {
|
||||
try {
|
||||
const requestUrl = new URL(req.url || '/', `http://127.0.0.1:${port}`);
|
||||
for (const handler of routeHandlers) {
|
||||
if (await handler(req, res, requestUrl, ctx)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
sendJson(res, 404, { success: false, error: `No route for ${req.method} ${requestUrl.pathname}` });
|
||||
} catch (error) {
|
||||
logger.error('Host API request failed:', error);
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(port, '127.0.0.1', () => {
|
||||
logger.info(`Host API server listening on http://127.0.0.1:${port}`);
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type {
|
||||
Extension,
|
||||
ExtensionContext,
|
||||
MarketplaceProviderExtension,
|
||||
MarketplaceCapability,
|
||||
} from '../types';
|
||||
import type {
|
||||
MarketplaceSearchParams,
|
||||
MarketplaceInstallParams,
|
||||
MarketplaceSkillResult,
|
||||
} from '../../gateway/clawhub';
|
||||
|
||||
class ClawHubMarketplaceExtension implements MarketplaceProviderExtension {
|
||||
readonly id = 'builtin/clawhub-marketplace';
|
||||
|
||||
setup(_ctx: ExtensionContext): void {
|
||||
// Built-in public ClawHub marketplace is disabled in community builds.
|
||||
}
|
||||
|
||||
async getCapability(): Promise<MarketplaceCapability> {
|
||||
return {
|
||||
mode: 'local-only',
|
||||
canSearch: false,
|
||||
canInstall: false,
|
||||
reason: 'marketplace-disabled',
|
||||
};
|
||||
}
|
||||
|
||||
async search(_params: MarketplaceSearchParams): Promise<MarketplaceSkillResult[]> {
|
||||
throw new Error('Marketplace search is disabled');
|
||||
}
|
||||
|
||||
async install(_params: MarketplaceInstallParams): Promise<void> {
|
||||
throw new Error('Marketplace install is disabled');
|
||||
}
|
||||
}
|
||||
|
||||
export function createClawHubMarketplaceExtension(): Extension {
|
||||
return new ClawHubMarketplaceExtension();
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { createDiagnosticsApi } from '../../services/diagnostics-api';
|
||||
import type { HostApiContribution, RuntimeHostAction } from '../../main/ipc/host-contract';
|
||||
import type {
|
||||
Extension,
|
||||
ExtensionContext,
|
||||
HostApiProviderExtension,
|
||||
} from '../types';
|
||||
|
||||
class DiagnosticsExtension implements HostApiProviderExtension {
|
||||
readonly id = 'builtin/diagnostics';
|
||||
|
||||
setup(_ctx: ExtensionContext): void {
|
||||
// Diagnostics are exposed through host IPC contributions.
|
||||
}
|
||||
|
||||
getHostApiContributions(ctx: ExtensionContext): HostApiContribution[] {
|
||||
const diagnostics = createDiagnosticsApi({
|
||||
gatewayManager: ctx.gatewayManager,
|
||||
runtimeManager: ctx.runtimeManager,
|
||||
});
|
||||
const actions: Record<string, RuntimeHostAction> = {
|
||||
gatewaySnapshot: () => diagnostics.gatewaySnapshot(),
|
||||
acpTrace: () => diagnostics.acpTrace(),
|
||||
recordAcpTrace: (payload) => diagnostics.recordAcpTrace(
|
||||
payload as Parameters<typeof diagnostics.recordAcpTrace>[0],
|
||||
),
|
||||
};
|
||||
return [{
|
||||
module: 'diagnostics',
|
||||
actions,
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
export function createDiagnosticsExtension(): Extension {
|
||||
return new DiagnosticsExtension();
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { registerBuiltinExtension } from '../loader';
|
||||
import { createClawHubMarketplaceExtension } from './clawhub-marketplace';
|
||||
import { createDiagnosticsExtension } from './diagnostics';
|
||||
|
||||
export function registerAllBuiltinExtensions(): void {
|
||||
registerBuiltinExtension('builtin/clawhub-marketplace', createClawHubMarketplaceExtension);
|
||||
registerBuiltinExtension('builtin/diagnostics', createDiagnosticsExtension);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export { extensionRegistry } from './registry';
|
||||
export { registerBuiltinExtension, loadExtensionsFromManifest } from './loader';
|
||||
export type {
|
||||
Extension,
|
||||
ExtensionContext,
|
||||
HostApiProviderExtension,
|
||||
MarketplaceProviderExtension,
|
||||
MarketplaceCapability,
|
||||
AuthProviderExtension,
|
||||
AuthStatus,
|
||||
} from './types';
|
||||
export {
|
||||
isHostApiProviderExtension,
|
||||
isMarketplaceProviderExtension,
|
||||
isAuthProviderExtension,
|
||||
} from './types';
|
||||
@@ -0,0 +1,76 @@
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { app } from 'electron';
|
||||
import { logger } from '../utils/logger';
|
||||
import { extensionRegistry } from './registry';
|
||||
import type { Extension } from './types';
|
||||
|
||||
interface ExtensionManifest {
|
||||
extensions?: {
|
||||
main?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
const builtinModules = new Map<string, () => Extension>();
|
||||
|
||||
export function registerBuiltinExtension(id: string, factory: () => Extension): void {
|
||||
builtinModules.set(id, factory);
|
||||
}
|
||||
|
||||
function resolveManifestPath(): string {
|
||||
if (app.isPackaged) {
|
||||
return join(process.resourcesPath, 'clawx-extensions.json');
|
||||
}
|
||||
return join(app.getAppPath(), 'clawx-extensions.json');
|
||||
}
|
||||
|
||||
export async function loadExtensionsFromManifest(): Promise<void> {
|
||||
const manifestPath = resolveManifestPath();
|
||||
let manifest: ExtensionManifest = {};
|
||||
|
||||
if (existsSync(manifestPath)) {
|
||||
try {
|
||||
manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) as ExtensionManifest;
|
||||
logger.info(`[extensions] Loaded manifest from ${manifestPath}`);
|
||||
} catch (err) {
|
||||
logger.warn(`[extensions] Failed to parse ${manifestPath}, using defaults:`, err);
|
||||
}
|
||||
} else {
|
||||
logger.debug('[extensions] No clawx-extensions.json found, loading all builtin extensions');
|
||||
}
|
||||
|
||||
const mainExtensions = manifest.extensions?.main;
|
||||
|
||||
if (!mainExtensions || mainExtensions.length === 0) {
|
||||
for (const [id, factory] of builtinModules) {
|
||||
extensionRegistry.register(factory());
|
||||
logger.debug(`[extensions] Auto-registered builtin extension "${id}"`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (const extensionId of mainExtensions) {
|
||||
if (builtinModules.has(extensionId)) {
|
||||
extensionRegistry.register(builtinModules.get(extensionId)!());
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const mod = require(extensionId) as { default?: Extension; extension?: Extension };
|
||||
const ext = mod.default ?? mod.extension;
|
||||
if (ext && typeof ext.setup === 'function') {
|
||||
extensionRegistry.register(ext);
|
||||
} else {
|
||||
logger.warn(`[extensions] Module "${extensionId}" does not export a valid Extension`);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes('Cannot find module')) {
|
||||
logger.debug(`[extensions] "${extensionId}" not loadable at runtime (expected when using ext-bridge)`);
|
||||
} else {
|
||||
logger.warn(`[extensions] Failed to load extension "${extensionId}": ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { logger } from '../utils/logger';
|
||||
import type {
|
||||
Extension,
|
||||
ExtensionContext,
|
||||
MarketplaceProviderExtension,
|
||||
} from './types';
|
||||
import {
|
||||
isHostApiProviderExtension,
|
||||
isMarketplaceProviderExtension,
|
||||
} from './types';
|
||||
|
||||
class ExtensionRegistry {
|
||||
private extensions = new Map<string, Extension>();
|
||||
private ctx: ExtensionContext | null = null;
|
||||
private hostApiUnregisters = new Map<string, () => void>();
|
||||
|
||||
async initialize(ctx: ExtensionContext): Promise<void> {
|
||||
this.ctx = ctx;
|
||||
for (const ext of this.extensions.values()) {
|
||||
try {
|
||||
await ext.setup(ctx);
|
||||
this.registerHostApiContributions(ext, ctx);
|
||||
logger.info(`[extensions] Extension "${ext.id}" initialized`);
|
||||
} catch (err) {
|
||||
logger.error(`[extensions] Extension "${ext.id}" failed to initialize:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
register(extension: Extension): void {
|
||||
if (this.extensions.has(extension.id)) {
|
||||
logger.warn(`[extensions] Extension "${extension.id}" is already registered; skipping duplicate`);
|
||||
return;
|
||||
}
|
||||
this.extensions.set(extension.id, extension);
|
||||
logger.debug(`[extensions] Registered extension "${extension.id}"`);
|
||||
|
||||
if (this.ctx) {
|
||||
void Promise.resolve(extension.setup(this.ctx))
|
||||
.then(() => {
|
||||
if (this.ctx) {
|
||||
this.registerHostApiContributions(extension, this.ctx);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error(`[extensions] Late-registered extension "${extension.id}" failed to initialize:`, err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
get(id: string): Extension | undefined {
|
||||
return this.extensions.get(id);
|
||||
}
|
||||
|
||||
getAll(): Extension[] {
|
||||
return [...this.extensions.values()];
|
||||
}
|
||||
|
||||
getMarketplaceProvider(): MarketplaceProviderExtension | undefined {
|
||||
return this.getAll().find(isMarketplaceProviderExtension) as MarketplaceProviderExtension | undefined;
|
||||
}
|
||||
|
||||
async teardownAll(): Promise<void> {
|
||||
for (const ext of this.extensions.values()) {
|
||||
try {
|
||||
this.hostApiUnregisters.get(ext.id)?.();
|
||||
this.hostApiUnregisters.delete(ext.id);
|
||||
await ext.teardown?.();
|
||||
} catch (err) {
|
||||
logger.warn(`[extensions] Extension "${ext.id}" teardown failed:`, err);
|
||||
}
|
||||
}
|
||||
this.extensions.clear();
|
||||
this.ctx = null;
|
||||
}
|
||||
|
||||
private registerHostApiContributions(ext: Extension, ctx: ExtensionContext): void {
|
||||
this.hostApiUnregisters.get(ext.id)?.();
|
||||
this.hostApiUnregisters.delete(ext.id);
|
||||
|
||||
if (!isHostApiProviderExtension(ext)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contributions = ext.getHostApiContributions(ctx);
|
||||
if (contributions.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.hostApiUnregisters.set(ext.id, ctx.hostApi.register(ext.id, contributions));
|
||||
}
|
||||
}
|
||||
|
||||
export const extensionRegistry = new ExtensionRegistry();
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import type { GatewayManager } from '../gateway/manager';
|
||||
import type { RuntimeManager } from '../runtime/manager';
|
||||
import type { HostApiContribution, HostApiContributionRegistrar } from '../main/ipc/host-contract';
|
||||
import type {
|
||||
MarketplaceSearchParams,
|
||||
MarketplaceInstallParams,
|
||||
MarketplaceSkillResult,
|
||||
ClawHubSearchParams,
|
||||
ClawHubInstallParams,
|
||||
ClawHubSkillResult,
|
||||
} from '../gateway/clawhub';
|
||||
|
||||
export interface ExtensionContext {
|
||||
gatewayManager: GatewayManager;
|
||||
runtimeManager: RuntimeManager;
|
||||
getMainWindow: () => BrowserWindow | null;
|
||||
hostApi: HostApiContributionRegistrar;
|
||||
}
|
||||
|
||||
export interface Extension {
|
||||
id: string;
|
||||
setup(ctx: ExtensionContext): void | Promise<void>;
|
||||
teardown?(): void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface MarketplaceCapability {
|
||||
mode: string;
|
||||
canSearch: boolean;
|
||||
canInstall: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface MarketplaceProviderExtension extends Extension {
|
||||
getCapability(): Promise<MarketplaceCapability>;
|
||||
search(params: MarketplaceSearchParams): Promise<MarketplaceSkillResult[]>;
|
||||
install(params: MarketplaceInstallParams): Promise<void>;
|
||||
}
|
||||
|
||||
export interface HostApiProviderExtension extends Extension {
|
||||
getHostApiContributions(ctx: ExtensionContext): HostApiContribution[];
|
||||
}
|
||||
|
||||
export type LegacyMarketplaceSearchParams = ClawHubSearchParams;
|
||||
export type LegacyMarketplaceInstallParams = ClawHubInstallParams;
|
||||
export type LegacyMarketplaceSkillResult = ClawHubSkillResult;
|
||||
|
||||
export interface AuthStatus {
|
||||
authenticated: boolean;
|
||||
expired: boolean;
|
||||
user: { username: string; displayName: string; email: string } | null;
|
||||
}
|
||||
|
||||
export interface AuthProviderExtension extends Extension {
|
||||
getAuthStatus(): Promise<AuthStatus>;
|
||||
onStartup?(mainWindow: BrowserWindow): Promise<void>;
|
||||
}
|
||||
|
||||
export function isMarketplaceProviderExtension(ext: Extension): ext is MarketplaceProviderExtension {
|
||||
return 'getCapability' in ext && 'search' in ext && 'install' in ext;
|
||||
}
|
||||
|
||||
export function isHostApiProviderExtension(ext: Extension): ext is HostApiProviderExtension {
|
||||
return 'getHostApiContributions' in ext
|
||||
&& typeof (ext as HostApiProviderExtension).getHostApiContributions === 'function';
|
||||
}
|
||||
|
||||
export function isAuthProviderExtension(ext: Extension): ext is AuthProviderExtension {
|
||||
return 'getAuthStatus' in ext && typeof (ext as AuthProviderExtension).getAuthStatus === 'function';
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import type {
|
||||
GatewayDiagnosticsSnapshot,
|
||||
GatewayHealthSummary,
|
||||
GatewayStatus,
|
||||
} from './manager';
|
||||
import type { GatewayRuntimePayload } from '@shared/types/gateway';
|
||||
|
||||
export type GatewayCapabilityName = 'openclawHealth' | 'openclawStatus' | 'channels' | 'memory';
|
||||
|
||||
export interface GatewayCapabilityProbe {
|
||||
state: 'unknown' | 'healthy' | 'degraded';
|
||||
checkedAt?: number;
|
||||
durationMs?: number;
|
||||
error?: string;
|
||||
payload?: GatewayRuntimePayload;
|
||||
}
|
||||
|
||||
export interface GatewayCoreProbe {
|
||||
ok: boolean;
|
||||
checkedAt: number;
|
||||
durationMs?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface GatewayCapabilitySnapshot {
|
||||
core: {
|
||||
process: GatewayStatus['state'];
|
||||
transport: 'connected' | 'disconnected';
|
||||
rpcRouter: 'unknown' | 'ready' | 'blocked';
|
||||
lastProbe?: GatewayCoreProbe;
|
||||
};
|
||||
openclawHealth: GatewayCapabilityProbe;
|
||||
openclawStatus: GatewayCapabilityProbe;
|
||||
presence: GatewayCapabilityProbe;
|
||||
channels: GatewayCapabilityProbe;
|
||||
memory: GatewayCapabilityProbe;
|
||||
diagnostics: GatewayDiagnosticsSnapshot;
|
||||
summary?: GatewayHealthSummary;
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function capabilityFromPayload(payload: GatewayRuntimePayload, checkedAt = Date.now()): GatewayCapabilityProbe {
|
||||
return {
|
||||
state: 'healthy',
|
||||
checkedAt,
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
function capabilityFromError(error: unknown, checkedAt = Date.now()): GatewayCapabilityProbe {
|
||||
return {
|
||||
state: 'degraded',
|
||||
checkedAt,
|
||||
error: formatError(error),
|
||||
};
|
||||
}
|
||||
|
||||
const UNKNOWN_CAPABILITY: GatewayCapabilityProbe = { state: 'unknown' };
|
||||
|
||||
export class GatewayCapabilityMonitor {
|
||||
private openclawHealth: GatewayCapabilityProbe = UNKNOWN_CAPABILITY;
|
||||
private openclawStatus: GatewayCapabilityProbe = UNKNOWN_CAPABILITY;
|
||||
private presence: GatewayCapabilityProbe = UNKNOWN_CAPABILITY;
|
||||
private channels: GatewayCapabilityProbe = UNKNOWN_CAPABILITY;
|
||||
private memory: GatewayCapabilityProbe = UNKNOWN_CAPABILITY;
|
||||
private lastCoreProbe: GatewayCoreProbe | undefined;
|
||||
|
||||
recordOpenClawHealth(payload: GatewayRuntimePayload): void {
|
||||
this.openclawHealth = capabilityFromPayload(payload);
|
||||
}
|
||||
|
||||
recordOpenClawStatus(payload: GatewayRuntimePayload): void {
|
||||
this.openclawStatus = capabilityFromPayload(payload);
|
||||
}
|
||||
|
||||
recordPresence(payload: GatewayRuntimePayload): void {
|
||||
this.presence = capabilityFromPayload(payload);
|
||||
}
|
||||
|
||||
recordCoreProbe(probe: GatewayCoreProbe): void {
|
||||
this.lastCoreProbe = probe;
|
||||
}
|
||||
|
||||
recordCapabilitySuccess(name: GatewayCapabilityName, payload: GatewayRuntimePayload, durationMs?: number): void {
|
||||
const probe: GatewayCapabilityProbe = {
|
||||
state: 'healthy',
|
||||
checkedAt: Date.now(),
|
||||
durationMs,
|
||||
payload,
|
||||
};
|
||||
this.setCapability(name, probe);
|
||||
}
|
||||
|
||||
recordCapabilityFailure(name: GatewayCapabilityName, error: unknown, durationMs?: number): void {
|
||||
const probe = capabilityFromError(error);
|
||||
probe.durationMs = durationMs;
|
||||
this.setCapability(name, probe);
|
||||
}
|
||||
|
||||
buildSnapshot(params: {
|
||||
status: GatewayStatus;
|
||||
transportConnected: boolean;
|
||||
diagnostics: GatewayDiagnosticsSnapshot;
|
||||
summary?: GatewayHealthSummary;
|
||||
}): GatewayCapabilitySnapshot {
|
||||
return {
|
||||
core: {
|
||||
process: params.status.state,
|
||||
transport: params.transportConnected ? 'connected' : 'disconnected',
|
||||
rpcRouter: this.lastCoreProbe?.ok === false
|
||||
? 'blocked'
|
||||
: params.status.gatewayReady === true || this.lastCoreProbe?.ok === true
|
||||
? 'ready'
|
||||
: 'unknown',
|
||||
lastProbe: this.lastCoreProbe,
|
||||
},
|
||||
openclawHealth: this.openclawHealth,
|
||||
openclawStatus: this.openclawStatus,
|
||||
presence: this.presence,
|
||||
channels: this.channels,
|
||||
memory: this.memory,
|
||||
diagnostics: params.diagnostics,
|
||||
summary: params.summary,
|
||||
};
|
||||
}
|
||||
|
||||
private setCapability(name: GatewayCapabilityName, probe: GatewayCapabilityProbe): void {
|
||||
if (name === 'openclawHealth') {
|
||||
this.openclawHealth = probe;
|
||||
} else if (name === 'openclawStatus') {
|
||||
this.openclawStatus = probe;
|
||||
} else if (name === 'channels') {
|
||||
this.channels = probe;
|
||||
} else if (name === 'memory') {
|
||||
this.memory = probe;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import type { ChatRuntimeEvent } from '../../shared/chat-runtime-events';
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === 'object' ? value as Record<string, unknown> : null;
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim() ? value : undefined;
|
||||
}
|
||||
|
||||
function readNumber(value: unknown): number | undefined {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
type ChatRuntimeEventType = ChatRuntimeEvent['type'];
|
||||
type ChatRuntimeEventFor<T extends ChatRuntimeEventType> = Extract<ChatRuntimeEvent, { type: T }>;
|
||||
type ChatRuntimeEventBaseFor<T extends ChatRuntimeEventType> = Pick<
|
||||
ChatRuntimeEventFor<T>,
|
||||
'type' | 'runId' | 'sessionKey' | 'seq' | 'ts'
|
||||
>;
|
||||
|
||||
function withBase<T extends ChatRuntimeEventType>(
|
||||
type: T,
|
||||
payload: Record<string, unknown>,
|
||||
): ChatRuntimeEventBaseFor<T> | null {
|
||||
const runId = readString(payload.runId);
|
||||
if (!runId) return null;
|
||||
return {
|
||||
type,
|
||||
runId,
|
||||
sessionKey: readString(payload.sessionKey),
|
||||
seq: readNumber(payload.seq),
|
||||
ts: readNumber(payload.ts),
|
||||
} as ChatRuntimeEventBaseFor<T>;
|
||||
}
|
||||
|
||||
export function normalizeGatewayChatRuntimeEvent(payload: unknown): ChatRuntimeEvent | null {
|
||||
const raw = asRecord(payload);
|
||||
if (!raw) return null;
|
||||
|
||||
const stream = readString(raw.stream);
|
||||
const data = asRecord(raw.data) ?? raw;
|
||||
|
||||
if (stream === 'lifecycle') {
|
||||
const phase = readString(data.phase);
|
||||
if (phase === 'start') {
|
||||
const base = withBase('run.started', raw);
|
||||
return base
|
||||
? {
|
||||
...base,
|
||||
startedAt: readNumber(data.startedAt),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
if (phase === 'completed' || phase === 'done' || phase === 'finished') {
|
||||
const base = withBase('run.ended', raw);
|
||||
return base
|
||||
? {
|
||||
...base,
|
||||
status: 'completed',
|
||||
endedAt: readNumber(data.endedAt),
|
||||
livenessState: readString(data.livenessState),
|
||||
replayInvalid: typeof data.replayInvalid === 'boolean' ? data.replayInvalid : undefined,
|
||||
stopReason: readString(data.stopReason),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
if (phase === 'error' || phase === 'failed') {
|
||||
const base = withBase('run.ended', raw);
|
||||
return base
|
||||
? {
|
||||
...base,
|
||||
status: 'error',
|
||||
endedAt: readNumber(data.endedAt),
|
||||
error: readString(data.error),
|
||||
livenessState: readString(data.livenessState),
|
||||
replayInvalid: typeof data.replayInvalid === 'boolean' ? data.replayInvalid : undefined,
|
||||
stopReason: readString(data.stopReason),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
if (phase === 'aborted' || phase === 'cancelled') {
|
||||
const base = withBase('run.ended', raw);
|
||||
return base
|
||||
? {
|
||||
...base,
|
||||
status: 'aborted',
|
||||
endedAt: readNumber(data.endedAt),
|
||||
error: readString(data.error),
|
||||
stopReason: readString(data.stopReason),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (stream === 'assistant') {
|
||||
const base = withBase('assistant.delta', raw);
|
||||
return base
|
||||
? {
|
||||
...base,
|
||||
text: readString(data.text),
|
||||
delta: readString(data.delta),
|
||||
replace: typeof data.replace === 'boolean' ? data.replace : undefined,
|
||||
phase: readString(data.phase),
|
||||
mediaUrls: Array.isArray(data.mediaUrls)
|
||||
? data.mediaUrls.filter((value): value is string => typeof value === 'string' && value.length > 0)
|
||||
: undefined,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
if (stream === 'thinking') {
|
||||
const base = withBase('thinking.delta', raw);
|
||||
return base
|
||||
? {
|
||||
...base,
|
||||
text: readString(data.text),
|
||||
delta: readString(data.delta),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
if (stream === 'tool') {
|
||||
const phase = readString(data.phase);
|
||||
const toolCallId = readString(data.toolCallId);
|
||||
const name = readString(data.name);
|
||||
if (!toolCallId || !name) return null;
|
||||
|
||||
if (phase === 'start') {
|
||||
const base = withBase('tool.started', raw);
|
||||
return base ? { ...base, toolCallId, name, args: data.args } : null;
|
||||
}
|
||||
if (phase === 'update') {
|
||||
const base = withBase('tool.updated', raw);
|
||||
return base ? { ...base, toolCallId, name, partialResult: data.partialResult } : null;
|
||||
}
|
||||
if (phase === 'result' || phase === 'end') {
|
||||
const base = withBase('tool.completed', raw);
|
||||
return base
|
||||
? {
|
||||
...base,
|
||||
toolCallId,
|
||||
name,
|
||||
result: data.result,
|
||||
meta: data.meta,
|
||||
isError: typeof data.isError === 'boolean' ? data.isError : undefined,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (stream === 'command_output') {
|
||||
const base = withBase('command.output', raw);
|
||||
return base
|
||||
? {
|
||||
...base,
|
||||
itemId: readString(data.itemId),
|
||||
toolCallId: readString(data.toolCallId),
|
||||
name: readString(data.name),
|
||||
title: readString(data.title),
|
||||
output: readString(data.output),
|
||||
status: readString(data.status),
|
||||
phase: readString(data.phase),
|
||||
exitCode: readNumber(data.exitCode),
|
||||
durationMs: readNumber(data.durationMs),
|
||||
cwd: readString(data.cwd),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
if (stream === 'patch') {
|
||||
const base = withBase('patch.completed', raw);
|
||||
return base
|
||||
? {
|
||||
...base,
|
||||
itemId: readString(data.itemId),
|
||||
toolCallId: readString(data.toolCallId),
|
||||
name: readString(data.name),
|
||||
title: readString(data.title),
|
||||
summary: readString(data.summary),
|
||||
added: readNumber(data.added),
|
||||
modified: readNumber(data.modified),
|
||||
deleted: readNumber(data.deleted),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
if (stream === 'approval') {
|
||||
const base = withBase('approval.updated', raw);
|
||||
return base
|
||||
? {
|
||||
...base,
|
||||
itemId: readString(data.itemId),
|
||||
toolCallId: readString(data.toolCallId),
|
||||
title: readString(data.title),
|
||||
kind: readString(data.kind),
|
||||
phase: readString(data.phase),
|
||||
status: readString(data.status),
|
||||
message: readString(data.message),
|
||||
actions: Array.isArray(data.actions)
|
||||
? data.actions.flatMap((action) => {
|
||||
if (!action || typeof action !== 'object') return [];
|
||||
const record = action as Record<string, unknown>;
|
||||
const value = readString(record.action);
|
||||
if (!value) return [];
|
||||
const label = readString(record.label);
|
||||
return [{ action: value, ...(label ? { label } : {}) }];
|
||||
})
|
||||
: undefined,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
+148
-293
@@ -1,29 +1,29 @@
|
||||
/**
|
||||
* ClawHub Service
|
||||
* Manages interactions with the ClawHub CLI for skills management
|
||||
* Maintains marketplace-provider compatibility and managed skill uninstall/open helpers.
|
||||
*/
|
||||
import { spawn } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { app, shell } from 'electron';
|
||||
import { getOpenClawConfigDir, ensureDir, getClawHubCliBinPath, getClawHubCliEntryPath, quoteForCmd } from '../utils/paths';
|
||||
import { shell } from 'electron';
|
||||
import { getOpenClawConfigDir, ensureDir } from '../utils/paths';
|
||||
import { removeSkillConfig } from '../utils/skill-config';
|
||||
|
||||
export interface ClawHubSearchParams {
|
||||
export interface MarketplaceSearchParams {
|
||||
query: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface ClawHubInstallParams {
|
||||
export interface MarketplaceInstallParams {
|
||||
slug: string;
|
||||
version?: string;
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export interface ClawHubUninstallParams {
|
||||
export interface MarketplaceUninstallParams {
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export interface ClawHubSkillResult {
|
||||
export interface MarketplaceSkillResult {
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string;
|
||||
@@ -33,6 +33,11 @@ export interface ClawHubSkillResult {
|
||||
stars?: number;
|
||||
}
|
||||
|
||||
export type ClawHubSearchParams = MarketplaceSearchParams;
|
||||
export type ClawHubInstallParams = MarketplaceInstallParams;
|
||||
export type ClawHubUninstallParams = MarketplaceUninstallParams;
|
||||
export type ClawHubSkillResult = MarketplaceSkillResult;
|
||||
|
||||
export interface ClawHubInstalledSkillResult {
|
||||
slug: string;
|
||||
version: string;
|
||||
@@ -40,44 +45,154 @@ export interface ClawHubInstalledSkillResult {
|
||||
baseDir?: string;
|
||||
}
|
||||
|
||||
export interface MarketplaceProvider {
|
||||
getCapability(): Promise<{ mode: string; canSearch: boolean; canInstall: boolean; reason?: string }>;
|
||||
search(params: MarketplaceSearchParams): Promise<MarketplaceSkillResult[]>;
|
||||
install(params: MarketplaceInstallParams): Promise<void>;
|
||||
}
|
||||
|
||||
export class ClawHubService {
|
||||
private workDir: string;
|
||||
private cliPath: string;
|
||||
private cliEntryPath: string;
|
||||
private useNodeRunner: boolean;
|
||||
private ansiRegex: RegExp;
|
||||
private marketplaceProvider: MarketplaceProvider | null = null;
|
||||
|
||||
constructor() {
|
||||
// Use the user's OpenClaw config directory (~/.openclaw) for skill management
|
||||
// This avoids installing skills into the project's openclaw submodule
|
||||
this.workDir = getOpenClawConfigDir();
|
||||
ensureDir(this.workDir);
|
||||
|
||||
const binPath = getClawHubCliBinPath();
|
||||
const entryPath = getClawHubCliEntryPath();
|
||||
|
||||
this.cliEntryPath = entryPath;
|
||||
if (!app.isPackaged && fs.existsSync(binPath)) {
|
||||
this.cliPath = binPath;
|
||||
this.useNodeRunner = false;
|
||||
} else {
|
||||
this.cliPath = process.execPath;
|
||||
this.useNodeRunner = true;
|
||||
}
|
||||
const esc = String.fromCharCode(27);
|
||||
const csi = String.fromCharCode(155);
|
||||
const pattern = `(?:${esc}|${csi})[[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]`;
|
||||
this.ansiRegex = new RegExp(pattern, 'g');
|
||||
}
|
||||
|
||||
private stripAnsi(line: string): string {
|
||||
return line.replace(this.ansiRegex, '').trim();
|
||||
setMarketplaceProvider(provider: MarketplaceProvider): void {
|
||||
this.marketplaceProvider = provider;
|
||||
}
|
||||
|
||||
async getMarketplaceCapability(): Promise<{ mode: string; canSearch: boolean; canInstall: boolean; reason?: string }> {
|
||||
if (this.marketplaceProvider) {
|
||||
return this.marketplaceProvider.getCapability();
|
||||
}
|
||||
return {
|
||||
mode: 'local-only',
|
||||
canSearch: false,
|
||||
canInstall: false,
|
||||
reason: 'marketplace-disabled',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for skills via an extension-provided marketplace.
|
||||
*/
|
||||
async search(params: MarketplaceSearchParams): Promise<MarketplaceSkillResult[]> {
|
||||
if (this.marketplaceProvider) {
|
||||
return this.marketplaceProvider.search(params);
|
||||
}
|
||||
throw new Error('Marketplace search is disabled');
|
||||
}
|
||||
|
||||
/**
|
||||
* Explore marketplace skills via the registered marketplace provider.
|
||||
*/
|
||||
async explore(params: { limit?: number } = {}): Promise<MarketplaceSkillResult[]> {
|
||||
if (this.marketplaceProvider) {
|
||||
return this.marketplaceProvider.search({ query: '', limit: params.limit });
|
||||
}
|
||||
throw new Error('Marketplace search is disabled');
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a skill through an extension-provided marketplace.
|
||||
*/
|
||||
async install(params: MarketplaceInstallParams): Promise<void> {
|
||||
if (this.marketplaceProvider) {
|
||||
return this.marketplaceProvider.install(params);
|
||||
}
|
||||
throw new Error('Marketplace install is disabled');
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstall a managed skill and remove its stored config.
|
||||
*/
|
||||
async uninstall(params: ClawHubUninstallParams): Promise<void> {
|
||||
const fsPromises = fs.promises;
|
||||
|
||||
const skillDir = path.join(this.workDir, 'skills', params.slug);
|
||||
if (fs.existsSync(skillDir)) {
|
||||
console.log(`Deleting skill directory: ${skillDir}`);
|
||||
await fsPromises.rm(skillDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const lockFile = path.join(this.workDir, '.clawhub', 'lock.json');
|
||||
if (fs.existsSync(lockFile)) {
|
||||
try {
|
||||
const lockData = JSON.parse(fs.readFileSync(lockFile, 'utf8')) as {
|
||||
skills?: Record<string, unknown>;
|
||||
};
|
||||
if (lockData.skills && lockData.skills[params.slug]) {
|
||||
console.log(`Removing ${params.slug} from lock.json`);
|
||||
delete lockData.skills[params.slug];
|
||||
await fsPromises.writeFile(lockFile, JSON.stringify(lockData, null, 2));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to update ClawHub lock file:', err);
|
||||
}
|
||||
}
|
||||
|
||||
await removeSkillConfig(params.slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* List installed managed skills from the filesystem.
|
||||
*/
|
||||
async listInstalled(): Promise<ClawHubInstalledSkillResult[]> {
|
||||
const skillsRoot = path.join(this.workDir, 'skills');
|
||||
if (!fs.existsSync(skillsRoot)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = await fs.promises.readdir(skillsRoot, { withFileTypes: true });
|
||||
const items = await Promise.all(entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map(async (entry) => {
|
||||
const skillDir = path.join(skillsRoot, entry.name);
|
||||
const manifestPath = path.join(skillDir, 'SKILL.md');
|
||||
if (!fs.existsSync(manifestPath)) return null;
|
||||
|
||||
let version = 'unknown';
|
||||
const manifestJsonPath = path.join(skillDir, 'manifest.json');
|
||||
if (fs.existsSync(manifestJsonPath)) {
|
||||
try {
|
||||
const manifestJson = JSON.parse(await fs.promises.readFile(manifestJsonPath, 'utf8')) as { version?: string };
|
||||
version = manifestJson.version?.trim() || version;
|
||||
} catch {
|
||||
// Ignore malformed manifest.json
|
||||
}
|
||||
}
|
||||
|
||||
const originJsonPath = path.join(skillDir, '.clawhub', 'origin.json');
|
||||
if (fs.existsSync(originJsonPath)) {
|
||||
try {
|
||||
const originJson = JSON.parse(await fs.promises.readFile(originJsonPath, 'utf8')) as { installedVersion?: string };
|
||||
version = originJson.installedVersion?.trim() || version;
|
||||
} catch {
|
||||
// Ignore malformed origin.json
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
slug: entry.name,
|
||||
version,
|
||||
source: 'openclaw-managed',
|
||||
baseDir: skillDir,
|
||||
};
|
||||
}));
|
||||
return items.filter((item): item is NonNullable<typeof item> => item !== null);
|
||||
} catch (error) {
|
||||
console.error('ClawHub list error:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private extractFrontmatterName(skillManifestPath: string): string | null {
|
||||
try {
|
||||
const raw = fs.readFileSync(skillManifestPath, 'utf8');
|
||||
// Match the first frontmatter block and read `name: ...`
|
||||
const frontmatterMatch = raw.match(/^---\s*\n([\s\S]*?)\n---/);
|
||||
if (!frontmatterMatch) return null;
|
||||
const body = frontmatterMatch[1];
|
||||
@@ -123,257 +238,6 @@ export class ClawHubService {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a ClawHub CLI command
|
||||
*/
|
||||
private async runCommand(args: string[]): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this.useNodeRunner && !fs.existsSync(this.cliEntryPath)) {
|
||||
reject(new Error(`ClawHub CLI entry not found at: ${this.cliEntryPath}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.useNodeRunner && !fs.existsSync(this.cliPath)) {
|
||||
reject(new Error(`ClawHub CLI not found at: ${this.cliPath}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const commandArgs = this.useNodeRunner ? [this.cliEntryPath, ...args] : args;
|
||||
const displayCommand = [this.cliPath, ...commandArgs].join(' ');
|
||||
console.log(`Running ClawHub command: ${displayCommand}`);
|
||||
|
||||
const isWin = process.platform === 'win32';
|
||||
const useShell = isWin && !this.useNodeRunner;
|
||||
const { NODE_OPTIONS: _nodeOptions, ...baseEnv } = process.env;
|
||||
const env = {
|
||||
...baseEnv,
|
||||
CI: 'true',
|
||||
FORCE_COLOR: '0',
|
||||
};
|
||||
if (this.useNodeRunner) {
|
||||
env.ELECTRON_RUN_AS_NODE = '1';
|
||||
}
|
||||
const spawnCmd = useShell ? quoteForCmd(this.cliPath) : this.cliPath;
|
||||
const spawnArgs = useShell ? commandArgs.map(a => quoteForCmd(a)) : commandArgs;
|
||||
const child = spawn(spawnCmd, spawnArgs, {
|
||||
cwd: this.workDir,
|
||||
shell: useShell,
|
||||
env: {
|
||||
...env,
|
||||
CLAWHUB_WORKDIR: this.workDir,
|
||||
},
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
child.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
child.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
console.error('ClawHub process error:', error);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (code !== 0 && code !== null) {
|
||||
console.error(`ClawHub command failed with code ${code}`);
|
||||
console.error('Stderr:', stderr);
|
||||
reject(new Error(`Command failed: ${stderr || stdout}`));
|
||||
} else {
|
||||
resolve(stdout.trim());
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for skills
|
||||
*/
|
||||
async search(params: ClawHubSearchParams): Promise<ClawHubSkillResult[]> {
|
||||
try {
|
||||
// If query is empty, use 'explore' to show trending skills
|
||||
if (!params.query || params.query.trim() === '') {
|
||||
return this.explore({ limit: params.limit });
|
||||
}
|
||||
|
||||
const args = ['search', params.query];
|
||||
if (params.limit) {
|
||||
args.push('--limit', String(params.limit));
|
||||
}
|
||||
|
||||
const output = await this.runCommand(args);
|
||||
if (!output || output.includes('No skills found')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const lines = output.split('\n').filter(l => l.trim());
|
||||
return lines.map(line => {
|
||||
const cleanLine = this.stripAnsi(line);
|
||||
|
||||
// Format could be: slug vversion description (score)
|
||||
// Or sometimes: slug vversion description
|
||||
let match = cleanLine.match(/^(\S+)\s+v?(\d+\.\S+)\s+(.+)$/);
|
||||
if (match) {
|
||||
const slug = match[1];
|
||||
const version = match[2];
|
||||
let description = match[3];
|
||||
|
||||
// Clean up score if present at the end
|
||||
description = description.replace(/\(\d+\.\d+\)$/, '').trim();
|
||||
|
||||
return {
|
||||
slug,
|
||||
name: slug,
|
||||
version,
|
||||
description,
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback for new clawhub search format without version:
|
||||
// slug name/description (score)
|
||||
match = cleanLine.match(/^(\S+)\s+(.+)$/);
|
||||
if (match) {
|
||||
const slug = match[1];
|
||||
let description = match[2];
|
||||
|
||||
// Clean up score if present at the end
|
||||
description = description.replace(/\(\d+\.\d+\)$/, '').trim();
|
||||
|
||||
return {
|
||||
slug,
|
||||
name: slug,
|
||||
version: 'latest', // Fallback version since it's not provided
|
||||
description,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}).filter((s): s is ClawHubSkillResult => s !== null);
|
||||
} catch (error) {
|
||||
console.error('ClawHub search error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Explore trending skills
|
||||
*/
|
||||
async explore(params: { limit?: number } = {}): Promise<ClawHubSkillResult[]> {
|
||||
try {
|
||||
const args = ['explore'];
|
||||
if (params.limit) {
|
||||
args.push('--limit', String(params.limit));
|
||||
}
|
||||
|
||||
const output = await this.runCommand(args);
|
||||
if (!output) return [];
|
||||
|
||||
const lines = output.split('\n').filter(l => l.trim());
|
||||
return lines.map(line => {
|
||||
const cleanLine = this.stripAnsi(line);
|
||||
|
||||
// Format: slug vversion time description
|
||||
// Example: my-skill v1.0.0 2 hours ago A great skill
|
||||
const match = cleanLine.match(/^(\S+)\s+v?(\d+\.\S+)\s+(.+? ago|just now|yesterday)\s+(.+)$/i);
|
||||
if (match) {
|
||||
return {
|
||||
slug: match[1],
|
||||
name: match[1],
|
||||
version: match[2],
|
||||
description: match[4],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}).filter((s): s is ClawHubSkillResult => s !== null);
|
||||
} catch (error) {
|
||||
console.error('ClawHub explore error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a skill
|
||||
*/
|
||||
async install(params: ClawHubInstallParams): Promise<void> {
|
||||
const args = ['install', params.slug];
|
||||
|
||||
if (params.version) {
|
||||
args.push('--version', params.version);
|
||||
}
|
||||
|
||||
if (params.force) {
|
||||
args.push('--force');
|
||||
}
|
||||
|
||||
await this.runCommand(args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstall a skill
|
||||
*/
|
||||
async uninstall(params: ClawHubUninstallParams): Promise<void> {
|
||||
const fsPromises = fs.promises;
|
||||
|
||||
// 1. Delete the skill directory
|
||||
const skillDir = path.join(this.workDir, 'skills', params.slug);
|
||||
if (fs.existsSync(skillDir)) {
|
||||
console.log(`Deleting skill directory: ${skillDir}`);
|
||||
await fsPromises.rm(skillDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// 2. Remove from lock.json
|
||||
const lockFile = path.join(this.workDir, '.clawhub', 'lock.json');
|
||||
if (fs.existsSync(lockFile)) {
|
||||
try {
|
||||
const lockData = JSON.parse(fs.readFileSync(lockFile, 'utf8'));
|
||||
if (lockData.skills && lockData.skills[params.slug]) {
|
||||
console.log(`Removing ${params.slug} from lock.json`);
|
||||
delete lockData.skills[params.slug];
|
||||
await fsPromises.writeFile(lockFile, JSON.stringify(lockData, null, 2));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to update ClawHub lock file:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List installed skills
|
||||
*/
|
||||
async listInstalled(): Promise<ClawHubInstalledSkillResult[]> {
|
||||
try {
|
||||
const output = await this.runCommand(['list']);
|
||||
if (!output || output.includes('No installed skills')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const lines = output.split('\n').filter(l => l.trim());
|
||||
return lines.map(line => {
|
||||
const cleanLine = this.stripAnsi(line);
|
||||
const match = cleanLine.match(/^(\S+)\s+v?(\d+\.\S+)/);
|
||||
if (match) {
|
||||
const slug = match[1];
|
||||
return {
|
||||
slug,
|
||||
version: match[2],
|
||||
source: 'openclaw-managed',
|
||||
baseDir: path.join(this.workDir, 'skills', slug),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}).filter((s): s is ClawHubInstalledSkillResult => s !== null);
|
||||
} catch (error) {
|
||||
console.error('ClawHub list error:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private resolveSkillDir(skillKeyOrSlug: string, fallbackSlug?: string, preferredBaseDir?: string): string | null {
|
||||
const candidates = [skillKeyOrSlug, fallbackSlug]
|
||||
.filter((v): v is string => typeof v === 'string' && v.trim().length > 0)
|
||||
@@ -388,13 +252,9 @@ export class ClawHubService {
|
||||
return directSkillDir || this.resolveSkillDirByManifestName(uniqueCandidates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open skill README/manual in default editor
|
||||
*/
|
||||
async openSkillReadme(skillKeyOrSlug: string, fallbackSlug?: string, preferredBaseDir?: string): Promise<boolean> {
|
||||
const skillDir = this.resolveSkillDir(skillKeyOrSlug, fallbackSlug, preferredBaseDir);
|
||||
|
||||
// Try to find documentation file
|
||||
const possibleFiles = ['SKILL.md', 'README.md', 'skill.md', 'readme.md'];
|
||||
let targetFile = '';
|
||||
|
||||
@@ -409,7 +269,6 @@ export class ClawHubService {
|
||||
}
|
||||
|
||||
if (!targetFile) {
|
||||
// If no md file, just open the directory
|
||||
if (skillDir) {
|
||||
targetFile = skillDir;
|
||||
} else {
|
||||
@@ -418,7 +277,6 @@ export class ClawHubService {
|
||||
}
|
||||
|
||||
try {
|
||||
// Open file with default application
|
||||
await shell.openPath(targetFile);
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -427,9 +285,6 @@ export class ClawHubService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open skill path in file explorer
|
||||
*/
|
||||
async openSkillPath(skillKeyOrSlug: string, fallbackSlug?: string, preferredBaseDir?: string): Promise<boolean> {
|
||||
const skillDir = this.resolveSkillDir(skillKeyOrSlug, fallbackSlug, preferredBaseDir);
|
||||
if (!skillDir) {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
export const SUPERVISED_SYSTEMD_ENV_KEYS = [
|
||||
'OPENCLAW_SYSTEMD_UNIT',
|
||||
'INVOCATION_ID',
|
||||
'SYSTEMD_EXEC_PID',
|
||||
'JOURNAL_STREAM',
|
||||
] as const;
|
||||
|
||||
export type GatewayEnv = Record<string, string | undefined>;
|
||||
|
||||
/**
|
||||
* OpenClaw CLI treats certain environment variables as systemd supervisor hints.
|
||||
* When present in ClawX-owned child-process launches, it can mistakenly enter
|
||||
* a supervised process retry loop. Strip those variables so startup follows
|
||||
* ClawX lifecycle.
|
||||
*/
|
||||
export function stripSystemdSupervisorEnv(env: GatewayEnv): GatewayEnv {
|
||||
const next = { ...env };
|
||||
for (const key of SUPERVISED_SYSTEMD_ENV_KEYS) {
|
||||
delete next[key];
|
||||
}
|
||||
return next;
|
||||
}
|
||||
+576
-21
@@ -1,17 +1,52 @@
|
||||
import { app } from 'electron';
|
||||
import path from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import { existsSync, readFileSync, mkdirSync, readdirSync, rmSync, symlinkSync } from 'fs';
|
||||
import { homedir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
function fsPath(filePath: string): string {
|
||||
if (process.platform !== 'win32') return filePath;
|
||||
if (!filePath) return filePath;
|
||||
if (filePath.startsWith('\\\\?\\')) return filePath;
|
||||
const windowsPath = filePath.replace(/\//g, '\\');
|
||||
if (!path.win32.isAbsolute(windowsPath)) return windowsPath;
|
||||
if (windowsPath.startsWith('\\\\')) {
|
||||
return `\\\\?\\UNC\\${windowsPath.slice(2)}`;
|
||||
}
|
||||
return `\\\\?\\${windowsPath}`;
|
||||
}
|
||||
import { getAllSettings } from '../utils/store';
|
||||
import { getApiKey, getDefaultProvider, getProvider } from '../utils/secure-storage';
|
||||
import { getProviderEnvVar, getKeyableProviderTypes } from '../utils/provider-registry';
|
||||
import { getOpenClawDir, getOpenClawEntryPath, isOpenClawPresent } from '../utils/paths';
|
||||
import {
|
||||
getOpenClawConfigDir,
|
||||
getOpenClawDir,
|
||||
getOpenClawEntryPath,
|
||||
getOpenClawResolvedDir,
|
||||
getOpenClawSkillsDir,
|
||||
isOpenClawPresent,
|
||||
} from '../utils/paths';
|
||||
import { getUvMirrorEnv } from '../utils/uv-env';
|
||||
import { listConfiguredChannels } from '../utils/channel-config';
|
||||
import { syncGatewayTokenToConfig, syncBrowserConfigToOpenClaw, sanitizeOpenClawConfig } from '../utils/openclaw-auth';
|
||||
import { cleanupDanglingWeChatPluginState, listConfiguredChannelsFromConfig, readOpenClawConfig } from '../utils/channel-config';
|
||||
import { sanitizeOpenClawConfig, batchSyncConfigFields } from '../utils/openclaw-auth';
|
||||
import { buildProxyEnv, resolveProxySettings } from '../utils/proxy';
|
||||
import { syncProxyConfigToOpenClaw } from '../utils/openclaw-proxy';
|
||||
import { logger } from '../utils/logger';
|
||||
import { prependPathEntry } from '../utils/env-path';
|
||||
import { copyPluginFromNodeModules, fixupPluginManifest, cpSyncSafe, buildCandidateSources, repairTrustedOfficialPluginInstallRecords, removeTrustedOfficialPluginInstallRecord, syncTrustedOfficialPluginInstallRecord, resolvePluginNpmPackagePath } from '../utils/plugin-install';
|
||||
import { CLAWX_OPENAI_IMAGE_PROVIDER_KEY } from '../utils/openclaw-image-relay-constants';
|
||||
import { ensureOpenClaw2026_7_1UpgradeSnapshot } from '../utils/openclaw-upgrade-snapshot';
|
||||
import { stripSystemdSupervisorEnv } from './config-sync-env';
|
||||
import { cleanupAgentsSymlinkedSkills, cleanupStalePluginRuntimeDeps } from './skills-symlink-cleanup';
|
||||
import {
|
||||
buildPrelaunchMaintenanceCacheKey,
|
||||
directoryChildrenSignature,
|
||||
pathSignature,
|
||||
runCachedPrelaunchMaintenanceTask,
|
||||
type PrelaunchMaintenanceRunResult,
|
||||
type PrelaunchMaintenanceTaskName,
|
||||
} from './prelaunch-maintenance-cache';
|
||||
|
||||
|
||||
export interface GatewayLaunchContext {
|
||||
appSettings: Awaited<ReturnType<typeof getAllSettings>>;
|
||||
@@ -26,28 +61,506 @@ export interface GatewayLaunchContext {
|
||||
channelStartupSummary: string;
|
||||
}
|
||||
|
||||
export async function syncGatewayConfigBeforeLaunch(
|
||||
appSettings: Awaited<ReturnType<typeof getAllSettings>>,
|
||||
): Promise<void> {
|
||||
await syncProxyConfigToOpenClaw(appSettings);
|
||||
export interface GatewayPrelaunchSyncSummary {
|
||||
timingsMs: Record<string, number>;
|
||||
maintenance: Partial<Record<PrelaunchMaintenanceTaskName, PrelaunchMaintenanceRunResult>>;
|
||||
configuredChannels: string[];
|
||||
}
|
||||
|
||||
// ── Auto-upgrade bundled plugins on startup ──────────────────────
|
||||
|
||||
const CHANNEL_PLUGIN_MAP: Record<string, { dirName: string; npmName: string }> = {
|
||||
dingtalk: { dirName: 'dingtalk', npmName: '@soimy/dingtalk' },
|
||||
wecom: { dirName: 'wecom', npmName: '@wecom/wecom-openclaw-plugin' },
|
||||
feishu: { dirName: 'feishu-openclaw-plugin', npmName: '@larksuite/openclaw-lark' },
|
||||
discord: { dirName: 'discord', npmName: '@openclaw/discord' },
|
||||
qqbot: { dirName: 'qqbot', npmName: '@openclaw/qqbot' },
|
||||
whatsapp: { dirName: 'whatsapp', npmName: '@openclaw/whatsapp' },
|
||||
|
||||
'openclaw-weixin': { dirName: 'openclaw-weixin', npmName: '@tencent-weixin/openclaw-weixin' },
|
||||
[CLAWX_OPENAI_IMAGE_PROVIDER_KEY]: { dirName: CLAWX_OPENAI_IMAGE_PROVIDER_KEY, npmName: 'clawx-openai-image-plugin' },
|
||||
};
|
||||
|
||||
/**
|
||||
* OpenClaw ships some channel plugins as bundled extensions under
|
||||
* dist/extensions/. If ClawX previously mirrored one of those ids into
|
||||
* ~/.openclaw/extensions/, the stale copy overrides the bundled plugin.
|
||||
* Only remove extension copies whose id is actually bundled in the
|
||||
* currently resolved OpenClaw runtime (e.g. telegram in 2026.6.10).
|
||||
*/
|
||||
function listBundledOpenClawExtensionPluginIds(): string[] {
|
||||
const extensionsDir = join(getOpenClawResolvedDir(), 'dist', 'extensions');
|
||||
if (!existsSync(fsPath(extensionsDir))) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const pluginIds: string[] = [];
|
||||
for (const entry of readdirSync(fsPath(extensionsDir), { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const manifestPath = join(extensionsDir, entry.name, 'openclaw.plugin.json');
|
||||
if (!existsSync(fsPath(manifestPath))) continue;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(fsPath(manifestPath), 'utf-8')) as { id?: unknown };
|
||||
if (typeof parsed.id === 'string' && parsed.id.trim()) {
|
||||
pluginIds.push(parsed.id.trim());
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed manifests
|
||||
}
|
||||
}
|
||||
|
||||
return pluginIds;
|
||||
}
|
||||
|
||||
function cleanupStaleBuiltInExtensions(): void {
|
||||
for (const ext of listBundledOpenClawExtensionPluginIds()) {
|
||||
const extDir = join(homedir(), '.openclaw', 'extensions', ext);
|
||||
if (existsSync(fsPath(extDir))) {
|
||||
logger.info(`[plugin] Removing stale built-in extension copy: ${ext}`);
|
||||
try {
|
||||
rmSync(fsPath(extDir), { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
logger.warn(`[plugin] Failed to remove stale extension ${ext}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readPluginVersion(pkgJsonPath: string): string | null {
|
||||
try {
|
||||
const raw = readFileSync(fsPath(pkgJsonPath), 'utf-8');
|
||||
const parsed = JSON.parse(raw) as { version?: string };
|
||||
return parsed.version ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function measureSync<T>(timings: Record<string, number>, key: string, fn: () => T): T {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
timings[key] = Date.now() - startedAt;
|
||||
}
|
||||
}
|
||||
|
||||
async function measureAsync<T>(timings: Record<string, number>, key: string, fn: () => Promise<T>): Promise<T> {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
timings[key] = Date.now() - startedAt;
|
||||
}
|
||||
}
|
||||
|
||||
function appVersionForCache(): string {
|
||||
try {
|
||||
return app.getVersion();
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-upgrade all configured channel plugins before Gateway start.
|
||||
* - Packaged mode: uses bundled plugins from resources/ (includes deps)
|
||||
* - Dev mode: falls back to node_modules/ with pnpm-aware dep collection
|
||||
*/
|
||||
function ensureConfiguredPluginsUpgraded(configuredChannels: string[]): boolean {
|
||||
let succeeded = true;
|
||||
for (const channelType of configuredChannels) {
|
||||
const pluginInfo = CHANNEL_PLUGIN_MAP[channelType];
|
||||
if (!pluginInfo) continue;
|
||||
const { dirName, npmName } = pluginInfo;
|
||||
|
||||
const targetDir = join(homedir(), '.openclaw', 'extensions', dirName);
|
||||
const targetManifest = join(targetDir, 'openclaw.plugin.json');
|
||||
const isInstalled = existsSync(fsPath(targetManifest));
|
||||
const installedVersion = isInstalled ? readPluginVersion(join(targetDir, 'package.json')) : null;
|
||||
|
||||
// Try bundled sources first (packaged mode or if bundle-plugins was run)
|
||||
const bundledSources = buildCandidateSources(dirName);
|
||||
const bundledDir = bundledSources.find((dir) => existsSync(fsPath(join(dir, 'openclaw.plugin.json'))));
|
||||
|
||||
if (bundledDir) {
|
||||
const sourceVersion = readPluginVersion(join(bundledDir, 'package.json'));
|
||||
// Install or upgrade if version differs or plugin not installed
|
||||
if (!isInstalled || (sourceVersion && installedVersion && sourceVersion !== installedVersion)) {
|
||||
logger.info(`[plugin] ${isInstalled ? 'Auto-upgrading' : 'Installing'} ${channelType} plugin${isInstalled ? `: ${installedVersion} → ${sourceVersion}` : `: ${sourceVersion}`} (bundled)`);
|
||||
try {
|
||||
mkdirSync(fsPath(join(homedir(), '.openclaw', 'extensions')), { recursive: true });
|
||||
rmSync(fsPath(targetDir), { recursive: true, force: true });
|
||||
cpSyncSafe(bundledDir, targetDir);
|
||||
fixupPluginManifest(targetDir);
|
||||
syncTrustedOfficialPluginInstallRecord(dirName, targetDir);
|
||||
} catch (err) {
|
||||
logger.warn(`[plugin] Failed to ${isInstalled ? 'auto-upgrade' : 'install'} ${channelType} plugin:`, err);
|
||||
succeeded = false;
|
||||
}
|
||||
} else if (isInstalled) {
|
||||
// Same version already installed — still patch manifest ID in case it was
|
||||
// never corrected (e.g. installed before MANIFEST_ID_FIXES included this plugin).
|
||||
fixupPluginManifest(targetDir);
|
||||
syncTrustedOfficialPluginInstallRecord(dirName, targetDir);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Dev mode fallback: copy from node_modules/ with pnpm dep resolution
|
||||
if (!app.isPackaged) {
|
||||
const npmPkgPath = resolvePluginNpmPackagePath(npmName);
|
||||
if (npmPkgPath && existsSync(fsPath(join(npmPkgPath, 'openclaw.plugin.json')))) {
|
||||
const sourceVersion = readPluginVersion(join(npmPkgPath, 'package.json'));
|
||||
if (!sourceVersion) continue;
|
||||
// Skip only if installed AND same version — but still patch manifest ID.
|
||||
if (isInstalled && installedVersion && sourceVersion === installedVersion) {
|
||||
fixupPluginManifest(targetDir);
|
||||
syncTrustedOfficialPluginInstallRecord(dirName, targetDir);
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.info(`[plugin] ${isInstalled ? 'Auto-upgrading' : 'Installing'} ${channelType} plugin${isInstalled ? `: ${installedVersion} → ${sourceVersion}` : `: ${sourceVersion}`} (dev/node_modules)`);
|
||||
|
||||
try {
|
||||
mkdirSync(fsPath(join(homedir(), '.openclaw', 'extensions')), { recursive: true });
|
||||
copyPluginFromNodeModules(npmPkgPath, targetDir, npmName);
|
||||
fixupPluginManifest(targetDir);
|
||||
syncTrustedOfficialPluginInstallRecord(dirName, targetDir);
|
||||
} catch (err) {
|
||||
logger.warn(`[plugin] Failed to ${isInstalled ? 'auto-upgrade' : 'install'} ${channelType} plugin from node_modules:`, err);
|
||||
succeeded = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return succeeded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove channel plugin extensions from ~/.openclaw/extensions/ when their
|
||||
* corresponding channel is no longer configured. This prevents the Gateway
|
||||
* from scanning residual plugin manifests that were installed by a previous
|
||||
* configuration but are no longer needed.
|
||||
*/
|
||||
function cleanupUnconfiguredChannelPlugins(configuredChannels: string[]): boolean {
|
||||
let succeeded = true;
|
||||
const configuredSet = new Set(configuredChannels);
|
||||
|
||||
for (const [channelType, pluginInfo] of Object.entries(CHANNEL_PLUGIN_MAP)) {
|
||||
if (configuredSet.has(channelType)) continue;
|
||||
|
||||
const { dirName } = pluginInfo;
|
||||
const targetDir = join(homedir(), '.openclaw', 'extensions', dirName);
|
||||
if (!existsSync(fsPath(targetDir))) continue;
|
||||
|
||||
logger.info(`[plugin] Removing unconfigured channel plugin: ${channelType} (${dirName})`);
|
||||
try {
|
||||
rmSync(fsPath(targetDir), { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
logger.warn(`[plugin] Failed to remove unconfigured channel plugin ${channelType}:`, err);
|
||||
succeeded = false;
|
||||
}
|
||||
}
|
||||
return succeeded;
|
||||
}
|
||||
|
||||
function cleanupUnconfiguredChannelPluginInstallRecords(configuredChannels: string[]): void {
|
||||
const configuredSet = new Set(configuredChannels);
|
||||
for (const [channelType, { dirName }] of Object.entries(CHANNEL_PLUGIN_MAP)) {
|
||||
if (configuredSet.has(channelType)) continue;
|
||||
// Metadata can outlive the directory (for example after an interrupted
|
||||
// 2026.6.10 → 2026.7.1 migration). OpenClaw validates tracked records even
|
||||
// when the channel is no longer configured, so reconcile this on every
|
||||
// launch rather than hiding it behind the directory-maintenance cache.
|
||||
removeTrustedOfficialPluginInstallRecord(dirName);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveImageGenerationPrimary(config: unknown): string | null {
|
||||
if (!config || typeof config !== 'object') return null;
|
||||
const agents = (config as { agents?: unknown }).agents;
|
||||
if (!agents || typeof agents !== 'object') return null;
|
||||
const defaults = (agents as { defaults?: unknown }).defaults;
|
||||
if (!defaults || typeof defaults !== 'object') return null;
|
||||
const imageGenerationModel = (defaults as { imageGenerationModel?: unknown }).imageGenerationModel;
|
||||
if (typeof imageGenerationModel === 'string') return imageGenerationModel.trim() || null;
|
||||
if (imageGenerationModel && typeof imageGenerationModel === 'object') {
|
||||
const primary = (imageGenerationModel as { primary?: unknown }).primary;
|
||||
return typeof primary === 'string' && primary.trim() ? primary.trim() : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function withConfiguredImageGenerationPlugins(configuredChannels: string[], rawConfig: unknown): string[] {
|
||||
const next = [...configuredChannels];
|
||||
const primary = resolveImageGenerationPrimary(rawConfig);
|
||||
const provider = primary?.includes('/') ? primary.slice(0, primary.indexOf('/')).trim() : primary;
|
||||
if (provider === CLAWX_OPENAI_IMAGE_PROVIDER_KEY && !next.includes(CLAWX_OPENAI_IMAGE_PROVIDER_KEY)) {
|
||||
next.push(CLAWX_OPENAI_IMAGE_PROVIDER_KEY);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function buildPluginSourceSignatures(configuredChannels: string[]): Record<string, unknown> {
|
||||
const signatures: Record<string, unknown> = {};
|
||||
for (const channelType of [...configuredChannels].sort()) {
|
||||
const pluginInfo = CHANNEL_PLUGIN_MAP[channelType];
|
||||
if (!pluginInfo) continue;
|
||||
const bundledSources = buildCandidateSources(pluginInfo.dirName);
|
||||
const bundledDir = bundledSources.find((dir) => existsSync(fsPath(join(dir, 'openclaw.plugin.json'))));
|
||||
const devPkgPath = join(process.cwd(), 'node_modules', ...pluginInfo.npmName.split('/'));
|
||||
const sourceDir = bundledDir || (!app.isPackaged ? devPkgPath : '');
|
||||
signatures[channelType] = sourceDir
|
||||
? {
|
||||
sourceDir,
|
||||
manifest: pathSignature(join(sourceDir, 'openclaw.plugin.json')),
|
||||
packageJson: pathSignature(join(sourceDir, 'package.json')),
|
||||
}
|
||||
: 'missing';
|
||||
}
|
||||
return signatures;
|
||||
}
|
||||
|
||||
function buildPluginMaintenanceCacheKey(openclawDir: string, configuredChannels: string[]): string {
|
||||
return buildPrelaunchMaintenanceCacheKey({
|
||||
task: 'plugin-maintenance',
|
||||
appVersion: appVersionForCache(),
|
||||
openclawDir,
|
||||
cwd: process.cwd(),
|
||||
configuredChannels: [...configuredChannels].sort(),
|
||||
extensionsDir: directoryChildrenSignature(join(homedir(), '.openclaw', 'extensions')),
|
||||
sourceSignatures: buildPluginSourceSignatures(configuredChannels),
|
||||
});
|
||||
}
|
||||
|
||||
function buildSkillsSymlinkCleanupCacheKey(openclawDir: string): string {
|
||||
const workspaceSkillsDir = join(getOpenClawConfigDir(), 'workspace', 'skills');
|
||||
return buildPrelaunchMaintenanceCacheKey({
|
||||
task: 'skills-symlink-cleanup',
|
||||
appVersion: appVersionForCache(),
|
||||
openclawDir,
|
||||
skillsDir: getOpenClawSkillsDir(),
|
||||
skillsDirSignature: directoryChildrenSignature(getOpenClawSkillsDir()),
|
||||
workspaceSkillsDir,
|
||||
workspaceSkillsDirSignature: directoryChildrenSignature(workspaceSkillsDir),
|
||||
});
|
||||
}
|
||||
|
||||
function buildRuntimeDepsCleanupCacheKey(openclawDir: string): string {
|
||||
const runtimeDepsDir = join(getOpenClawConfigDir(), 'plugin-runtime-deps');
|
||||
return buildPrelaunchMaintenanceCacheKey({
|
||||
task: 'runtime-deps-cleanup',
|
||||
appVersion: appVersionForCache(),
|
||||
openclawDir,
|
||||
currentOpenClawDir: getOpenClawResolvedDir(),
|
||||
runtimeDepsDir,
|
||||
runtimeDepsDirSignature: directoryChildrenSignature(runtimeDepsDir),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure extension-specific packages are resolvable from shared dist/ chunks.
|
||||
*
|
||||
* OpenClaw's Rollup bundler creates shared chunks in dist/ (e.g.
|
||||
* sticker-cache-*.js) that eagerly `import "grammy"`. ESM bare specifier
|
||||
* resolution walks from the importing file's directory upward:
|
||||
* dist/node_modules/ → openclaw/node_modules/ → …
|
||||
* It does NOT search `dist/extensions/telegram/node_modules/`.
|
||||
*
|
||||
* NODE_PATH only works for CJS require(), NOT for ESM import statements.
|
||||
*
|
||||
* Fix: create symlinks in openclaw/node_modules/ pointing to packages in
|
||||
* dist/extensions/<ext>/node_modules/. This makes the standard ESM
|
||||
* resolution algorithm find them. Skip-if-exists avoids overwriting
|
||||
* openclaw's own deps (they take priority).
|
||||
*/
|
||||
let _extensionDepsLinked = false;
|
||||
|
||||
/**
|
||||
* Reset the extension-deps-linked cache so the next
|
||||
* ensureExtensionDepsResolvable() call re-scans and links.
|
||||
* Called before each Gateway launch to pick up newly installed extensions.
|
||||
*/
|
||||
export function resetExtensionDepsLinked(): void {
|
||||
_extensionDepsLinked = false;
|
||||
}
|
||||
|
||||
function ensureExtensionDepsResolvable(openclawDir: string): void {
|
||||
if (_extensionDepsLinked) return;
|
||||
|
||||
const extDir = join(openclawDir, 'dist', 'extensions');
|
||||
const topNM = join(openclawDir, 'node_modules');
|
||||
let linkedCount = 0;
|
||||
|
||||
try {
|
||||
await sanitizeOpenClawConfig();
|
||||
if (!existsSync(extDir)) return;
|
||||
|
||||
for (const ext of readdirSync(extDir, { withFileTypes: true })) {
|
||||
if (!ext.isDirectory()) continue;
|
||||
const extNM = join(extDir, ext.name, 'node_modules');
|
||||
if (!existsSync(extNM)) continue;
|
||||
|
||||
for (const pkg of readdirSync(extNM, { withFileTypes: true })) {
|
||||
if (pkg.name === '.bin') continue;
|
||||
|
||||
if (pkg.name.startsWith('@')) {
|
||||
// Scoped package — iterate sub-entries
|
||||
const scopeDir = join(extNM, pkg.name);
|
||||
let scopeEntries;
|
||||
try { scopeEntries = readdirSync(scopeDir, { withFileTypes: true }); } catch { continue; }
|
||||
for (const sub of scopeEntries) {
|
||||
if (!sub.isDirectory()) continue;
|
||||
const dest = join(topNM, pkg.name, sub.name);
|
||||
if (existsSync(dest)) continue;
|
||||
try {
|
||||
mkdirSync(join(topNM, pkg.name), { recursive: true });
|
||||
symlinkSync(join(scopeDir, sub.name), dest);
|
||||
linkedCount++;
|
||||
} catch { /* skip on error — non-fatal */ }
|
||||
}
|
||||
} else {
|
||||
const dest = join(topNM, pkg.name);
|
||||
if (existsSync(dest)) continue;
|
||||
try {
|
||||
mkdirSync(topNM, { recursive: true });
|
||||
symlinkSync(join(extNM, pkg.name), dest);
|
||||
linkedCount++;
|
||||
} catch { /* skip on error — non-fatal */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// extensions dir may not exist or be unreadable — non-fatal
|
||||
}
|
||||
|
||||
if (linkedCount > 0) {
|
||||
logger.info(`[extension-deps] Linked ${linkedCount} extension packages into ${topNM}`);
|
||||
}
|
||||
|
||||
_extensionDepsLinked = true;
|
||||
}
|
||||
|
||||
// ── Pre-launch sync ──────────────────────────────────────────────
|
||||
|
||||
export async function syncGatewayConfigBeforeLaunch(
|
||||
appSettings: Awaited<ReturnType<typeof getAllSettings>>,
|
||||
openclawDir: string,
|
||||
): Promise<GatewayPrelaunchSyncSummary> {
|
||||
const timingsMs: Record<string, number> = {};
|
||||
const maintenance: GatewayPrelaunchSyncSummary['maintenance'] = {};
|
||||
let configuredChannels: string[] = [];
|
||||
|
||||
// Reset the extension-deps cache so that newly installed extensions
|
||||
// (e.g. user added a channel while the app was running) get their
|
||||
// node_modules linked on the next Gateway spawn.
|
||||
resetExtensionDepsLinked();
|
||||
|
||||
await measureAsync(timingsMs, 'proxySyncMs', async () => {
|
||||
await syncProxyConfigToOpenClaw(appSettings, { preserveExistingWhenDisabled: true });
|
||||
});
|
||||
|
||||
try {
|
||||
await measureAsync(timingsMs, 'sanitizeMs', sanitizeOpenClawConfig);
|
||||
} catch (err) {
|
||||
logger.warn('Failed to sanitize openclaw.json:', err);
|
||||
}
|
||||
|
||||
try {
|
||||
await syncGatewayTokenToConfig(appSettings.gatewayToken);
|
||||
await measureAsync(timingsMs, 'wechatStateCleanupMs', cleanupDanglingWeChatPluginState);
|
||||
} catch (err) {
|
||||
logger.warn('Failed to sync gateway token to openclaw.json:', err);
|
||||
logger.warn('Failed to clean dangling WeChat plugin state before launch:', err);
|
||||
}
|
||||
|
||||
// Remove stale copies of built-in extensions (Discord, Telegram) that
|
||||
// override OpenClaw's working built-in plugins and break channel loading.
|
||||
try {
|
||||
await syncBrowserConfigToOpenClaw();
|
||||
measureSync(timingsMs, 'staleBuiltinExtensionCleanupMs', cleanupStaleBuiltInExtensions);
|
||||
} catch (err) {
|
||||
logger.warn('Failed to sync browser config to openclaw.json:', err);
|
||||
logger.warn('Failed to clean stale built-in extensions:', err);
|
||||
}
|
||||
|
||||
// Remove stray symlinks under ~/.openclaw/skills whose realpath resolves
|
||||
// inside ~/.agents/skills. OpenClaw's hardened skill loader rejects these
|
||||
// on every launch (reason=symlink-escape) and the underlying skills are
|
||||
// still discovered via the agents-skills-personal source, so the symlinks
|
||||
// are pure log noise. Transitional workaround for openclaw/openclaw#59219.
|
||||
try {
|
||||
const result = measureSync(timingsMs, 'skillsCleanupMs', () => runCachedPrelaunchMaintenanceTask(
|
||||
'skills-symlink-cleanup',
|
||||
() => buildSkillsSymlinkCleanupCacheKey(openclawDir),
|
||||
() => (cleanupAgentsSymlinkedSkills().failed ?? 0) === 0,
|
||||
));
|
||||
maintenance['skills-symlink-cleanup'] = result;
|
||||
} catch (err) {
|
||||
logger.warn('Failed to clean .agents/skills-targeted skill symlinks:', err);
|
||||
}
|
||||
|
||||
// Remove stale OpenClaw runtime-deps cache roots that point at an older
|
||||
// worktree/package. Those symlink trees can make Gateway plugin setup spend
|
||||
// a long time in synchronous fs.open/copy calls before the RPC router is
|
||||
// responsive.
|
||||
try {
|
||||
const result = measureSync(timingsMs, 'runtimeDepsCleanupMs', () => runCachedPrelaunchMaintenanceTask(
|
||||
'runtime-deps-cleanup',
|
||||
() => buildRuntimeDepsCleanupCacheKey(openclawDir),
|
||||
() => (cleanupStalePluginRuntimeDeps().failed ?? 0) === 0,
|
||||
));
|
||||
maintenance['runtime-deps-cleanup'] = result;
|
||||
} catch (err) {
|
||||
logger.warn('Failed to clean stale OpenClaw plugin runtime deps:', err);
|
||||
}
|
||||
|
||||
// Auto-upgrade installed plugins before Gateway starts so that
|
||||
// the plugin manifest ID matches what sanitize wrote to the config.
|
||||
// Only install/upgrade plugins for channels that are actually configured
|
||||
// in openclaw.json — do NOT expand the list from plugins.allow.
|
||||
try {
|
||||
configuredChannels = await measureAsync(timingsMs, 'configuredChannelsMs', async () => {
|
||||
const rawCfg = await readOpenClawConfig();
|
||||
return withConfiguredImageGenerationPlugins(
|
||||
await listConfiguredChannelsFromConfig(rawCfg),
|
||||
rawCfg,
|
||||
);
|
||||
});
|
||||
|
||||
const result = measureSync(timingsMs, 'pluginMaintenanceMs', () => runCachedPrelaunchMaintenanceTask(
|
||||
'plugin-maintenance',
|
||||
() => buildPluginMaintenanceCacheKey(openclawDir, configuredChannels),
|
||||
() => {
|
||||
const upgradeOk = ensureConfiguredPluginsUpgraded(configuredChannels);
|
||||
const cleanupOk = cleanupUnconfiguredChannelPlugins(configuredChannels);
|
||||
return upgradeOk && cleanupOk;
|
||||
},
|
||||
));
|
||||
maintenance['plugin-maintenance'] = result;
|
||||
// Always refresh trusted install metadata through ClawX — this must not
|
||||
// be skipped when plugin-maintenance is cache-hit, otherwise official
|
||||
// external plugins like WhatsApp fail openKeyedStore at runtime.
|
||||
measureSync(timingsMs, 'trustedPluginInstallSyncMs', () => {
|
||||
cleanupUnconfiguredChannelPluginInstallRecords(configuredChannels);
|
||||
repairTrustedOfficialPluginInstallRecords();
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn('Failed to auto-upgrade plugins:', err);
|
||||
}
|
||||
|
||||
// Batch gateway token, browser config, and session idle into one read+write cycle.
|
||||
try {
|
||||
await measureAsync(timingsMs, 'configFieldSyncMs', async () => {
|
||||
await batchSyncConfigFields(appSettings.gatewayToken);
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn('Failed to batch-sync config fields to openclaw.json:', err);
|
||||
}
|
||||
|
||||
return {
|
||||
timingsMs,
|
||||
maintenance,
|
||||
configuredChannels,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadProviderEnv(): Promise<{ providerEnv: Record<string, string>; loadedProviderKeyCount: number }> {
|
||||
@@ -96,7 +609,8 @@ async function resolveChannelStartupPolicy(): Promise<{
|
||||
channelStartupSummary: string;
|
||||
}> {
|
||||
try {
|
||||
const configuredChannels = await listConfiguredChannels();
|
||||
const rawCfg = await readOpenClawConfig();
|
||||
const configuredChannels = await listConfiguredChannelsFromConfig(rawCfg);
|
||||
if (configuredChannels.length === 0) {
|
||||
return {
|
||||
skipChannels: true,
|
||||
@@ -118,6 +632,8 @@ async function resolveChannelStartupPolicy(): Promise<{
|
||||
}
|
||||
|
||||
export async function prepareGatewayLaunchContext(port: number): Promise<GatewayLaunchContext> {
|
||||
const timingsMs: Record<string, number> = {};
|
||||
const totalStartedAt = Date.now();
|
||||
const openclawDir = getOpenClawDir();
|
||||
const entryScript = getOpenClawEntryPath();
|
||||
|
||||
@@ -125,8 +641,23 @@ export async function prepareGatewayLaunchContext(port: number): Promise<Gateway
|
||||
throw new Error(`OpenClaw package not found at: ${openclawDir}`);
|
||||
}
|
||||
|
||||
const appSettings = await getAllSettings();
|
||||
await syncGatewayConfigBeforeLaunch(appSettings);
|
||||
await measureAsync(timingsMs, 'upgradeSnapshotMs', async () => {
|
||||
try {
|
||||
const snapshot = await ensureOpenClaw2026_7_1UpgradeSnapshot();
|
||||
if (snapshot.status === 'created') {
|
||||
logger.info(`[upgrade] Created OpenClaw 2026.7.1 pre-migration snapshot (${snapshot.files.length} files): ${snapshot.snapshotDir}`);
|
||||
}
|
||||
} catch (error) {
|
||||
// OpenClaw also maintains migration-specific backups. Keep startup
|
||||
// available if the additional ClawX safety snapshot cannot be written.
|
||||
logger.warn('[upgrade] Failed to create OpenClaw 2026.7.1 pre-migration snapshot:', error);
|
||||
}
|
||||
});
|
||||
|
||||
const appSettings = await measureAsync(timingsMs, 'settingsMs', getAllSettings);
|
||||
const prelaunchSummary = await measureAsync(timingsMs, 'prelaunchSyncMs', async () => (
|
||||
await syncGatewayConfigBeforeLaunch(appSettings, openclawDir)
|
||||
));
|
||||
|
||||
if (!existsSync(entryScript)) {
|
||||
throw new Error(`OpenClaw entry script not found at: ${entryScript}`);
|
||||
@@ -143,9 +674,13 @@ export async function prepareGatewayLaunchContext(port: number): Promise<Gateway
|
||||
: path.join(process.cwd(), 'resources', 'bin', target);
|
||||
const binPathExists = existsSync(binPath);
|
||||
|
||||
const { providerEnv, loadedProviderKeyCount } = await loadProviderEnv();
|
||||
const { skipChannels, channelStartupSummary } = await resolveChannelStartupPolicy();
|
||||
const uvEnv = await getUvMirrorEnv();
|
||||
const { providerEnv, loadedProviderKeyCount } = await measureAsync(timingsMs, 'providerEnvMs', loadProviderEnv);
|
||||
const { skipChannels, channelStartupSummary } = await measureAsync(
|
||||
timingsMs,
|
||||
'channelStartupPolicyMs',
|
||||
resolveChannelStartupPolicy,
|
||||
);
|
||||
const uvEnv = await measureAsync(timingsMs, 'uvEnvMs', getUvMirrorEnv);
|
||||
const proxyEnv = buildProxyEnv(appSettings);
|
||||
const resolvedProxy = resolveProxySettings(appSettings);
|
||||
const proxySummary = appSettings.proxyEnabled
|
||||
@@ -158,16 +693,36 @@ export async function prepareGatewayLaunchContext(port: number): Promise<Gateway
|
||||
? prependPathEntry(baseEnvRecord, binPath).env
|
||||
: baseEnvRecord;
|
||||
const forkEnv: Record<string, string | undefined> = {
|
||||
...baseEnvPatched,
|
||||
...stripSystemdSupervisorEnv(baseEnvPatched),
|
||||
...providerEnv,
|
||||
...uvEnv,
|
||||
...proxyEnv,
|
||||
OPENCLAW_GATEWAY_TOKEN: appSettings.gatewayToken,
|
||||
OPENCLAW_SKIP_CHANNELS: skipChannels ? '1' : '',
|
||||
CLAWDBOT_SKIP_CHANNELS: skipChannels ? '1' : '',
|
||||
OPENCLAW_NO_RESPAWN: '1',
|
||||
// Disable OpenClaw's interactive-shell env snapshot. When the Gateway runs
|
||||
// as an Electron utilityProcess, `process.execPath` is the Electron binary,
|
||||
// and OpenClaw captures the shell env by spawning `process.execPath -e
|
||||
// <script>` inside a sanitized login shell that strips ELECTRON_RUN_AS_NODE.
|
||||
// Electron then treats the script as an app path and pops up "Unable to find
|
||||
// Electron app at <cwd>/const safe = new Set(...)". Turning the snapshot off
|
||||
// avoids that broken spawn; exec tools fall back to the Gateway launch env.
|
||||
OPENCLAW_EXEC_SHELL_SNAPSHOT: '0',
|
||||
};
|
||||
|
||||
// Ensure extension-specific packages (e.g. grammy from the telegram
|
||||
// extension) are resolvable by shared dist/ chunks via symlinks in
|
||||
// openclaw/node_modules/. NODE_PATH does NOT work for ESM imports.
|
||||
measureSync(timingsMs, 'extensionDepsMs', () => ensureExtensionDepsResolvable(openclawDir));
|
||||
timingsMs.totalMs = Date.now() - totalStartedAt;
|
||||
|
||||
logger.info('[metric] gateway.prelaunch', {
|
||||
...prelaunchSummary.timingsMs,
|
||||
...timingsMs,
|
||||
maintenance: prelaunchSummary.maintenance,
|
||||
configuredChannelCount: prelaunchSummary.configuredChannels.length,
|
||||
});
|
||||
|
||||
return {
|
||||
appSettings,
|
||||
openclawDir,
|
||||
|
||||
@@ -1,21 +1,78 @@
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
type HealthResult = { ok: boolean; error?: string };
|
||||
type HeartbeatAliveReason = 'pong' | 'message';
|
||||
|
||||
type PingOptions = {
|
||||
sendPing: () => void;
|
||||
onHeartbeatTimeout: (context: { consecutiveMisses: number; timeoutMs: number }) => void;
|
||||
intervalMs?: number;
|
||||
timeoutMs?: number;
|
||||
maxConsecutiveMisses?: number;
|
||||
};
|
||||
|
||||
export class GatewayConnectionMonitor {
|
||||
private pingInterval: NodeJS.Timeout | null = null;
|
||||
private healthCheckInterval: NodeJS.Timeout | null = null;
|
||||
private lastPingAt = 0;
|
||||
private waitingForAlive = false;
|
||||
private consecutiveMisses = 0;
|
||||
private timeoutTriggered = false;
|
||||
|
||||
startPing(options: PingOptions): void {
|
||||
const intervalMs = options.intervalMs ?? 30000;
|
||||
const timeoutMs = options.timeoutMs ?? 10000;
|
||||
const maxConsecutiveMisses = Math.max(1, options.maxConsecutiveMisses ?? 3);
|
||||
this.resetHeartbeatState();
|
||||
|
||||
startPing(sendPing: () => void, intervalMs = 30000): void {
|
||||
if (this.pingInterval) {
|
||||
clearInterval(this.pingInterval);
|
||||
}
|
||||
|
||||
this.pingInterval = setInterval(() => {
|
||||
sendPing();
|
||||
const now = Date.now();
|
||||
|
||||
if (this.waitingForAlive && now - this.lastPingAt >= timeoutMs) {
|
||||
this.waitingForAlive = false;
|
||||
this.consecutiveMisses += 1;
|
||||
logger.warn(
|
||||
`Gateway heartbeat missed (${this.consecutiveMisses}/${maxConsecutiveMisses}, timeout=${timeoutMs}ms)`,
|
||||
);
|
||||
if (this.consecutiveMisses >= maxConsecutiveMisses && !this.timeoutTriggered) {
|
||||
this.timeoutTriggered = true;
|
||||
options.onHeartbeatTimeout({
|
||||
consecutiveMisses: this.consecutiveMisses,
|
||||
timeoutMs,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
options.sendPing();
|
||||
this.waitingForAlive = true;
|
||||
this.lastPingAt = now;
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
markAlive(reason: HeartbeatAliveReason): void {
|
||||
// Only log true recovery cases to avoid steady-state heartbeat log spam.
|
||||
if (this.consecutiveMisses > 0) {
|
||||
logger.debug(`Gateway heartbeat recovered via ${reason} (misses=${this.consecutiveMisses})`);
|
||||
}
|
||||
this.waitingForAlive = false;
|
||||
this.consecutiveMisses = 0;
|
||||
this.timeoutTriggered = false;
|
||||
}
|
||||
|
||||
// Backward-compatible alias for old callers.
|
||||
handlePong(): void {
|
||||
this.markAlive('pong');
|
||||
}
|
||||
|
||||
getConsecutiveMisses(): number {
|
||||
return this.consecutiveMisses;
|
||||
}
|
||||
|
||||
startHealthCheck(options: {
|
||||
shouldCheck: () => boolean;
|
||||
checkHealth: () => Promise<HealthResult>;
|
||||
@@ -55,5 +112,13 @@ export class GatewayConnectionMonitor {
|
||||
clearInterval(this.healthCheckInterval);
|
||||
this.healthCheckInterval = null;
|
||||
}
|
||||
this.resetHeartbeatState();
|
||||
}
|
||||
|
||||
private resetHeartbeatState(): void {
|
||||
this.lastPingAt = 0;
|
||||
this.waitingForAlive = false;
|
||||
this.consecutiveMisses = 0;
|
||||
this.timeoutTriggered = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { GatewayEventType, type JsonRpcNotification } from './protocol';
|
||||
import { logger } from '../utils/logger';
|
||||
import { normalizeGatewayChatRuntimeEvent } from './chat-runtime-events';
|
||||
import type {
|
||||
GatewayChannelStatusEvent,
|
||||
GatewayChatMessageEvent,
|
||||
GatewayRuntimePayload,
|
||||
} from '@shared/host-events/contract';
|
||||
|
||||
type GatewayEventEmitter = {
|
||||
emit: (event: string, payload: unknown) => boolean;
|
||||
@@ -17,23 +23,26 @@ export function dispatchProtocolEvent(
|
||||
emitter.emit('chat:message', { message: payload });
|
||||
break;
|
||||
case 'agent': {
|
||||
const p = payload as Record<string, unknown>;
|
||||
const data = (p.data && typeof p.data === 'object') ? p.data as Record<string, unknown> : {};
|
||||
const chatEvent: Record<string, unknown> = {
|
||||
...data,
|
||||
runId: p.runId ?? data.runId,
|
||||
sessionKey: p.sessionKey ?? data.sessionKey,
|
||||
state: p.state ?? data.state,
|
||||
message: p.message ?? data.message,
|
||||
};
|
||||
if (chatEvent.state || chatEvent.message) {
|
||||
emitter.emit('chat:message', { message: chatEvent });
|
||||
const normalized = normalizeGatewayChatRuntimeEvent(payload);
|
||||
if (normalized) {
|
||||
emitter.emit('chat:runtime-event', normalized);
|
||||
}
|
||||
emitter.emit('notification', { method: event, params: payload });
|
||||
break;
|
||||
}
|
||||
case 'channel.status':
|
||||
emitter.emit('channel:status', payload as { channelId: string; status: string });
|
||||
case 'channel.status_changed':
|
||||
emitter.emit('channel:status', payload as GatewayChannelStatusEvent);
|
||||
break;
|
||||
case 'gateway.ready':
|
||||
case 'ready':
|
||||
emitter.emit('gateway:ready', payload);
|
||||
break;
|
||||
case 'health':
|
||||
emitter.emit('gateway:health', payload as GatewayRuntimePayload);
|
||||
break;
|
||||
case 'presence':
|
||||
emitter.emit('gateway:presence', payload as GatewayRuntimePayload);
|
||||
break;
|
||||
default:
|
||||
emitter.emit('notification', { method: event, params: payload });
|
||||
@@ -45,12 +54,18 @@ export function dispatchJsonRpcNotification(
|
||||
notification: JsonRpcNotification,
|
||||
): void {
|
||||
emitter.emit('notification', notification);
|
||||
if (notification.method === 'agent') {
|
||||
const normalized = normalizeGatewayChatRuntimeEvent(notification.params);
|
||||
if (normalized) {
|
||||
emitter.emit('chat:runtime-event', normalized);
|
||||
}
|
||||
}
|
||||
switch (notification.method) {
|
||||
case GatewayEventType.CHANNEL_STATUS_CHANGED:
|
||||
emitter.emit('channel:status', notification.params as { channelId: string; status: string });
|
||||
emitter.emit('channel:status', notification.params as GatewayChannelStatusEvent);
|
||||
break;
|
||||
case GatewayEventType.MESSAGE_RECEIVED:
|
||||
emitter.emit('chat:message', notification.params as { message: unknown });
|
||||
emitter.emit('chat:message', notification.params as GatewayChatMessageEvent);
|
||||
break;
|
||||
case GatewayEventType.ERROR: {
|
||||
const errorData = notification.params as { message?: string };
|
||||
|
||||
+758
-39
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,160 @@
|
||||
import { app } from 'electron';
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const CACHE_SCHEMA_VERSION = 1;
|
||||
const CACHE_FILE_NAME = 'gateway-prelaunch-maintenance-cache.json';
|
||||
|
||||
export type PrelaunchMaintenanceTaskName =
|
||||
| 'plugin-maintenance'
|
||||
| 'runtime-deps-cleanup'
|
||||
| 'skills-symlink-cleanup';
|
||||
|
||||
export interface PrelaunchMaintenanceRunResult {
|
||||
executed: boolean;
|
||||
reason: 'cache-hit' | 'cache-miss' | 'cache-unavailable' | 'task-failed';
|
||||
}
|
||||
|
||||
type CacheKeyInput = string | (() => string);
|
||||
type MaintenanceTask = () => void | boolean;
|
||||
|
||||
interface CacheEntry {
|
||||
key: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface CacheFile {
|
||||
schemaVersion: number;
|
||||
tasks: Partial<Record<PrelaunchMaintenanceTaskName, CacheEntry>>;
|
||||
}
|
||||
|
||||
function getDefaultCachePath(): string {
|
||||
return join(app.getPath('userData'), CACHE_FILE_NAME);
|
||||
}
|
||||
|
||||
function emptyCache(): CacheFile {
|
||||
return {
|
||||
schemaVersion: CACHE_SCHEMA_VERSION,
|
||||
tasks: {},
|
||||
};
|
||||
}
|
||||
|
||||
function readCache(cachePath: string): CacheFile | null {
|
||||
try {
|
||||
if (!existsSync(cachePath)) return emptyCache();
|
||||
const parsed = JSON.parse(readFileSync(cachePath, 'utf-8')) as CacheFile;
|
||||
if (parsed.schemaVersion !== CACHE_SCHEMA_VERSION || !parsed.tasks) {
|
||||
return emptyCache();
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCache(cachePath: string, cache: CacheFile): boolean {
|
||||
try {
|
||||
mkdirSync(dirname(cachePath), { recursive: true });
|
||||
writeFileSync(cachePath, `${JSON.stringify(cache, null, 2)}\n`, 'utf-8');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function stableJson(value: unknown): string {
|
||||
if (value == null || typeof value !== 'object') return JSON.stringify(value);
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((item) => stableJson(item)).join(',')}]`;
|
||||
}
|
||||
const entries = Object.entries(value as Record<string, unknown>)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, entryValue]) => `${JSON.stringify(key)}:${stableJson(entryValue)}`);
|
||||
return `{${entries.join(',')}}`;
|
||||
}
|
||||
|
||||
export function pathSignature(path: string): string {
|
||||
try {
|
||||
const stat = statSync(path);
|
||||
return `${stat.isDirectory() ? 'dir' : 'file'}:${Math.round(stat.mtimeMs)}:${stat.size}`;
|
||||
} catch {
|
||||
return 'missing';
|
||||
}
|
||||
}
|
||||
|
||||
export function directoryChildrenSignature(path: string, maxEntries = 200): string {
|
||||
try {
|
||||
const entries = readdirSync(path, { withFileTypes: true, encoding: 'utf8' })
|
||||
.sort((left, right) => left.name.localeCompare(right.name))
|
||||
.slice(0, maxEntries)
|
||||
.map((entry) => {
|
||||
const childPath = join(path, entry.name);
|
||||
return [
|
||||
entry.name,
|
||||
entry.isDirectory() ? 'dir' : entry.isSymbolicLink() ? 'symlink' : 'file',
|
||||
pathSignature(childPath),
|
||||
].join(':');
|
||||
});
|
||||
return stableJson(entries);
|
||||
} catch {
|
||||
return 'missing';
|
||||
}
|
||||
}
|
||||
|
||||
export function buildPrelaunchMaintenanceCacheKey(parts: Record<string, unknown>): string {
|
||||
return stableJson({
|
||||
schemaVersion: CACHE_SCHEMA_VERSION,
|
||||
...parts,
|
||||
});
|
||||
}
|
||||
|
||||
export function runCachedPrelaunchMaintenanceTask(
|
||||
taskName: PrelaunchMaintenanceTaskName,
|
||||
cacheKey: CacheKeyInput,
|
||||
task: MaintenanceTask,
|
||||
options: { cachePath?: string } = {},
|
||||
): PrelaunchMaintenanceRunResult {
|
||||
const readCacheKey = (): string => (typeof cacheKey === 'function' ? cacheKey() : cacheKey);
|
||||
const cachePath = options.cachePath ?? getDefaultCachePath();
|
||||
const cache = readCache(cachePath);
|
||||
if (!cache) {
|
||||
task();
|
||||
return { executed: true, reason: 'cache-unavailable' };
|
||||
}
|
||||
|
||||
let initialCacheKey: string;
|
||||
try {
|
||||
initialCacheKey = readCacheKey();
|
||||
} catch {
|
||||
task();
|
||||
return { executed: true, reason: 'cache-unavailable' };
|
||||
}
|
||||
|
||||
if (cache.tasks[taskName]?.key === initialCacheKey) {
|
||||
return { executed: false, reason: 'cache-hit' };
|
||||
}
|
||||
|
||||
const taskResult = task();
|
||||
if (taskResult === false) {
|
||||
return { executed: true, reason: 'task-failed' };
|
||||
}
|
||||
let finalCacheKey: string;
|
||||
try {
|
||||
finalCacheKey = readCacheKey();
|
||||
} catch {
|
||||
return { executed: true, reason: 'cache-unavailable' };
|
||||
}
|
||||
cache.tasks[taskName] = {
|
||||
key: finalCacheKey,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
writeCache(cachePath, cache);
|
||||
return { executed: true, reason: 'cache-miss' };
|
||||
}
|
||||
@@ -32,8 +32,10 @@ const GATEWAY_FETCH_PRELOAD_SOURCE = `'use strict';
|
||||
delete flat['HTTP-Referer'];
|
||||
delete flat['x-title'];
|
||||
delete flat['X-Title'];
|
||||
delete flat['x-openrouter-title'];
|
||||
delete flat['X-OpenRouter-Title'];
|
||||
flat['HTTP-Referer'] = 'https://claw-x.com';
|
||||
flat['X-Title'] = 'ClawX';
|
||||
flat['X-OpenRouter-Title'] = 'ClawX';
|
||||
init.headers = flat;
|
||||
}
|
||||
return _f.call(globalThis, input, init);
|
||||
@@ -78,6 +80,20 @@ const GATEWAY_FETCH_PRELOAD_SOURCE = `'use strict';
|
||||
})();
|
||||
`;
|
||||
|
||||
export function buildGatewayRuntimeEnv(
|
||||
forkEnv: Record<string, string | undefined>,
|
||||
): Record<string, string | undefined> {
|
||||
return {
|
||||
...forkEnv,
|
||||
// ClawX does not expose LAN discovery, so keep Bonjour disabled even if
|
||||
// the parent process inherited an explicit opt-in value.
|
||||
OPENCLAW_DISABLE_BONJOUR: '1',
|
||||
// OpenClaw's built-in trace contains stage names and timings only. Keep it
|
||||
// enabled so packaged startup incidents are diagnosable from normal logs.
|
||||
OPENCLAW_GATEWAY_STARTUP_TRACE: '1',
|
||||
};
|
||||
}
|
||||
|
||||
function ensureGatewayFetchPreload(): string {
|
||||
const dest = path.join(app.getPath('userData'), 'gateway-fetch-preload.cjs');
|
||||
try {
|
||||
@@ -116,7 +132,31 @@ export async function launchGatewayProcess(options: {
|
||||
);
|
||||
const lastSpawnSummary = `mode=${mode}, entry="${entryScript}", args="${options.sanitizeSpawnArgs(gatewayArgs).join(' ')}", cwd="${openclawDir}"`;
|
||||
|
||||
const runtimeEnv = { ...forkEnv };
|
||||
const runtimeEnv = buildGatewayRuntimeEnv(forkEnv);
|
||||
|
||||
// Disable OpenClaw's mDNS/Bonjour gateway advertiser unconditionally.
|
||||
//
|
||||
// The OpenClaw gateway advertises `_openclaw-gw._tcp.local` on every
|
||||
// active network interface using a hardcoded `openclaw.local` hostname,
|
||||
// which causes:
|
||||
// - cross-machine name collisions when multiple OpenClaw/ClawX peers
|
||||
// share a LAN (each falls back to "<name> (OpenClaw) (2)")
|
||||
// - self-collisions on multi-homed hosts (Wi-Fi + Tailscale + utun ...)
|
||||
// - "ghost" record collisions after an unclean ClawX exit, because
|
||||
// SIGKILL prevents ciao from emitting the mDNS goodbye record.
|
||||
//
|
||||
// ClawX has no UI for LAN gateway discovery today, so the advertiser is
|
||||
// pure log noise. `OPENCLAW_DISABLE_BONJOUR=1` short-circuits
|
||||
// `startGatewayBonjourAdvertiser()` (openclaw `src/infra/bonjour.ts`,
|
||||
// `isDisabledByEnv()`). Set after the `forkEnv` spread so any
|
||||
// pre-existing value inherited from the user shell cannot re-enable it.
|
||||
// buildGatewayRuntimeEnv() applies both this policy and startup tracing
|
||||
// before any development-only environment augmentation below.
|
||||
|
||||
// Only apply the fetch/child_process preload in dev mode.
|
||||
// In packaged builds Electron's UtilityProcess rejects NODE_OPTIONS
|
||||
// with --require, logging "Most NODE_OPTIONs are not supported in
|
||||
// packaged apps" and the preload never loads.
|
||||
if (!app.isPackaged) {
|
||||
try {
|
||||
const preloadPath = ensureGatewayFetchPreload();
|
||||
@@ -151,14 +191,20 @@ export async function launchGatewayProcess(options: {
|
||||
reject(error);
|
||||
};
|
||||
|
||||
child.on('error', (error) => {
|
||||
child.on('error', (error: unknown) => {
|
||||
const normalizedError = error instanceof Error ? error : new Error(String(error));
|
||||
logger.error('Gateway process spawn error:', error);
|
||||
options.onError(error);
|
||||
rejectOnce(error);
|
||||
options.onError(normalizedError);
|
||||
rejectOnce(normalizedError);
|
||||
});
|
||||
|
||||
child.on('exit', (code: number) => {
|
||||
const expectedExit = !options.getShouldReconnect() || options.getCurrentState() === 'stopped';
|
||||
// Only check shouldReconnect — not current state. On Windows the WS
|
||||
// close handler fires before the process exit handler and sets state to
|
||||
// 'stopped', which would make an unexpected crash look like a planned
|
||||
// shutdown in logs. shouldReconnect is the reliable indicator: stop()
|
||||
// sets it to false (expected), crashes leave it true (unexpected).
|
||||
const expectedExit = !options.getShouldReconnect();
|
||||
const level = expectedExit ? logger.info : logger.warn;
|
||||
level(`Gateway process exited (code=${code}, expected=${expectedExit ? 'yes' : 'no'})`);
|
||||
options.onExit(child, code);
|
||||
|
||||
@@ -10,6 +10,13 @@ export const DEFAULT_RECONNECT_CONFIG: ReconnectConfig = {
|
||||
maxDelay: 30000,
|
||||
};
|
||||
|
||||
/** sysexits(3) EX_CONFIG, used by OpenClaw 2026.7.1 for fatal config startup errors. */
|
||||
export const OPENCLAW_EX_CONFIG_EXIT_CODE = 78;
|
||||
|
||||
export function isOpenClawFatalConfigExitCode(code: number | null | undefined): boolean {
|
||||
return code === OPENCLAW_EX_CONFIG_EXIT_CODE;
|
||||
}
|
||||
|
||||
export function nextLifecycleEpoch(currentEpoch: number): number {
|
||||
return currentEpoch + 1;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export type GatewayReloadMode = 'hybrid' | 'reload' | 'restart' | 'off';
|
||||
|
||||
export type GatewayReloadPolicy = {
|
||||
mode: GatewayReloadMode;
|
||||
debounceMs: number;
|
||||
};
|
||||
|
||||
export const DEFAULT_GATEWAY_RELOAD_POLICY: GatewayReloadPolicy = {
|
||||
mode: 'hybrid',
|
||||
debounceMs: 1200,
|
||||
};
|
||||
|
||||
const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json');
|
||||
const MAX_DEBOUNCE_MS = 60_000;
|
||||
|
||||
function normalizeMode(value: unknown): GatewayReloadMode {
|
||||
if (value === 'off' || value === 'reload' || value === 'restart' || value === 'hybrid') {
|
||||
return value;
|
||||
}
|
||||
return DEFAULT_GATEWAY_RELOAD_POLICY.mode;
|
||||
}
|
||||
|
||||
function normalizeDebounceMs(value: unknown): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return DEFAULT_GATEWAY_RELOAD_POLICY.debounceMs;
|
||||
}
|
||||
const rounded = Math.round(value);
|
||||
if (rounded < 0) return 0;
|
||||
if (rounded > MAX_DEBOUNCE_MS) return MAX_DEBOUNCE_MS;
|
||||
return rounded;
|
||||
}
|
||||
|
||||
export function parseGatewayReloadPolicy(config: unknown): GatewayReloadPolicy {
|
||||
if (!config || typeof config !== 'object') {
|
||||
return { ...DEFAULT_GATEWAY_RELOAD_POLICY };
|
||||
}
|
||||
const root = config as Record<string, unknown>;
|
||||
const gateway = (root.gateway && typeof root.gateway === 'object'
|
||||
? root.gateway
|
||||
: {}) as Record<string, unknown>;
|
||||
const reload = (gateway.reload && typeof gateway.reload === 'object'
|
||||
? gateway.reload
|
||||
: {}) as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
mode: normalizeMode(reload.mode),
|
||||
debounceMs: normalizeDebounceMs(reload.debounceMs),
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadGatewayReloadPolicy(): Promise<GatewayReloadPolicy> {
|
||||
try {
|
||||
const raw = await readFile(OPENCLAW_CONFIG_PATH, 'utf-8');
|
||||
return parseGatewayReloadPolicy(JSON.parse(raw));
|
||||
} catch {
|
||||
return { ...DEFAULT_GATEWAY_RELOAD_POLICY };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ type DeferredRestartContext = RestartDeferralState & {
|
||||
|
||||
export class GatewayRestartController {
|
||||
private deferredRestartPending = false;
|
||||
private deferredRestartRequestedAt = 0;
|
||||
private lastRestartCompletedAt = 0;
|
||||
private restartDebounceTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
isRestartDeferred(context: RestartDeferralState): boolean {
|
||||
@@ -33,6 +35,13 @@ export class GatewayRestartController {
|
||||
);
|
||||
}
|
||||
this.deferredRestartPending = true;
|
||||
if (this.deferredRestartRequestedAt === 0) {
|
||||
this.deferredRestartRequestedAt = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
recordRestartCompleted(): void {
|
||||
this.lastRestartCompletedAt = Date.now();
|
||||
}
|
||||
|
||||
flushDeferredRestart(
|
||||
@@ -55,7 +64,9 @@ export class GatewayRestartController {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestedAt = this.deferredRestartRequestedAt;
|
||||
this.deferredRestartPending = false;
|
||||
this.deferredRestartRequestedAt = 0;
|
||||
if (action === 'drop') {
|
||||
logger.info(
|
||||
`Dropping deferred Gateway restart (${trigger}) because lifecycle already recovered (state=${context.state}, shouldReconnect=${context.shouldReconnect})`,
|
||||
@@ -63,6 +74,16 @@ export class GatewayRestartController {
|
||||
return;
|
||||
}
|
||||
|
||||
// If a restart already completed after this deferred request was made,
|
||||
// the current process is already running with the latest config —
|
||||
// skip the redundant restart to avoid "just started then restart" loops.
|
||||
if (requestedAt > 0 && this.lastRestartCompletedAt >= requestedAt) {
|
||||
logger.info(
|
||||
`Dropping deferred Gateway restart (${trigger}): a restart already completed after the request (requested=${requestedAt}, completed=${this.lastRestartCompletedAt})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Executing deferred Gateway restart now (${trigger})`);
|
||||
executeRestart();
|
||||
}
|
||||
@@ -87,5 +108,6 @@ export class GatewayRestartController {
|
||||
|
||||
resetDeferredRestart(): void {
|
||||
this.deferredRestartPending = false;
|
||||
this.deferredRestartRequestedAt = 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
export type RestartDecision =
|
||||
| { allow: true }
|
||||
| {
|
||||
allow: false;
|
||||
reason: 'cooldown_active';
|
||||
retryAfterMs: number;
|
||||
};
|
||||
|
||||
type RestartGovernorOptions = {
|
||||
/** Minimum interval between consecutive restarts (ms). */
|
||||
cooldownMs: number;
|
||||
};
|
||||
|
||||
const DEFAULT_OPTIONS: RestartGovernorOptions = {
|
||||
cooldownMs: 2500,
|
||||
};
|
||||
|
||||
/**
|
||||
* Lightweight restart rate-limiter.
|
||||
*
|
||||
* Prevents rapid-fire restarts by enforcing a simple cooldown between
|
||||
* consecutive restart executions. Nothing more — no circuit breakers,
|
||||
* no sliding-window budgets, no exponential back-off. Those mechanisms
|
||||
* were previously present but removed because:
|
||||
*
|
||||
* 1. The root causes of infinite restart loops (stale ownedPid, port
|
||||
* contention, leaked WebSocket connections) have been fixed at their
|
||||
* source.
|
||||
* 2. A 10-minute circuit-breaker lockout actively hurt the user
|
||||
* experience: legitimate config changes were silently dropped.
|
||||
* 3. The complexity made the restart path harder to reason about during
|
||||
* debugging.
|
||||
*/
|
||||
export class GatewayRestartGovernor {
|
||||
private readonly options: RestartGovernorOptions;
|
||||
private lastRestartAt = 0;
|
||||
private suppressedTotal = 0;
|
||||
private executedTotal = 0;
|
||||
|
||||
constructor(options?: Partial<RestartGovernorOptions>) {
|
||||
this.options = { ...DEFAULT_OPTIONS, ...options };
|
||||
}
|
||||
|
||||
/** No-op kept for interface compatibility with callers. */
|
||||
onRunning(_now = Date.now()): void {
|
||||
// Previously used to track "stable running" for exponential back-off
|
||||
// reset. No longer needed with the simplified cooldown model.
|
||||
}
|
||||
|
||||
decide(now = Date.now()): RestartDecision {
|
||||
if (this.lastRestartAt > 0) {
|
||||
const sinceLast = now - this.lastRestartAt;
|
||||
if (sinceLast < this.options.cooldownMs) {
|
||||
this.suppressedTotal = this.safeIncrement(this.suppressedTotal);
|
||||
return {
|
||||
allow: false,
|
||||
reason: 'cooldown_active',
|
||||
retryAfterMs: this.options.cooldownMs - sinceLast,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { allow: true };
|
||||
}
|
||||
|
||||
recordExecuted(now = Date.now()): void {
|
||||
this.executedTotal = this.safeIncrement(this.executedTotal);
|
||||
this.lastRestartAt = now;
|
||||
}
|
||||
|
||||
getCounters(): { executedTotal: number; suppressedTotal: number } {
|
||||
return {
|
||||
executedTotal: this.executedTotal,
|
||||
suppressedTotal: this.suppressedTotal,
|
||||
};
|
||||
}
|
||||
|
||||
getObservability(): {
|
||||
suppressed_total: number;
|
||||
executed_total: number;
|
||||
circuit_open_until: number;
|
||||
} {
|
||||
return {
|
||||
suppressed_total: this.suppressedTotal,
|
||||
executed_total: this.executedTotal,
|
||||
circuit_open_until: 0, // Always 0 — no circuit breaker
|
||||
};
|
||||
}
|
||||
|
||||
private safeIncrement(current: number): number {
|
||||
if (current >= Number.MAX_SAFE_INTEGER) return 0;
|
||||
return current + 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
type GatewayRpcRunner = (method: string, params?: unknown, timeoutMs?: number) => Promise<unknown>;
|
||||
|
||||
type QueuedRpc = {
|
||||
run: () => Promise<void>;
|
||||
};
|
||||
|
||||
function stableStringify(value: unknown): string {
|
||||
if (value === null || typeof value !== 'object') {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((item) => stableStringify(item)).join(',')}]`;
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
return `{${Object.keys(record).sort().map((key) => (
|
||||
`${JSON.stringify(key)}:${stableStringify(record[key])}`
|
||||
)).join(',')}}`;
|
||||
}
|
||||
|
||||
export interface GatewayRpcBackpressureOptions {
|
||||
maxConcurrentHistory?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevents renderer fan-out from forwarding an unbounded number of expensive
|
||||
* chat.history RPCs to OpenClaw. The Gateway still owns the canonical response;
|
||||
* this class only coalesces duplicate in-flight history calls and runs distinct
|
||||
* history requests through a small FIFO queue.
|
||||
*/
|
||||
export class GatewayRpcBackpressure {
|
||||
private readonly maxConcurrentHistory: number;
|
||||
private readonly inFlightHistory = new Map<string, Promise<unknown>>();
|
||||
private readonly queue: QueuedRpc[] = [];
|
||||
private activeHistory = 0;
|
||||
|
||||
constructor(options: GatewayRpcBackpressureOptions = {}) {
|
||||
this.maxConcurrentHistory = Math.max(1, options.maxConcurrentHistory ?? 2);
|
||||
}
|
||||
|
||||
run(
|
||||
method: string,
|
||||
params: unknown,
|
||||
timeoutMs: number | undefined,
|
||||
runner: GatewayRpcRunner,
|
||||
): Promise<unknown> {
|
||||
if (method !== 'chat.history') {
|
||||
return runner(method, params, timeoutMs);
|
||||
}
|
||||
|
||||
const key = `${method}:${stableStringify(params)}:${timeoutMs ?? 'default'}`;
|
||||
const existing = this.inFlightHistory.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const promise = this.enqueueHistory(() => runner(method, params, timeoutMs))
|
||||
.finally(() => {
|
||||
if (this.inFlightHistory.get(key) === promise) {
|
||||
this.inFlightHistory.delete(key);
|
||||
}
|
||||
});
|
||||
this.inFlightHistory.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
getDiagnostics(): { activeHistory: number; queuedHistory: number; inFlightHistory: number } {
|
||||
return {
|
||||
activeHistory: this.activeHistory,
|
||||
queuedHistory: this.queue.length,
|
||||
inFlightHistory: this.inFlightHistory.size,
|
||||
};
|
||||
}
|
||||
|
||||
private enqueueHistory(work: () => Promise<unknown>): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const queued: QueuedRpc = {
|
||||
run: async () => {
|
||||
this.activeHistory += 1;
|
||||
try {
|
||||
resolve(await work());
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
} finally {
|
||||
this.activeHistory -= 1;
|
||||
this.drain();
|
||||
}
|
||||
},
|
||||
};
|
||||
this.queue.push(queued);
|
||||
this.drain();
|
||||
});
|
||||
}
|
||||
|
||||
private drain(): void {
|
||||
while (this.activeHistory < this.maxConcurrentHistory) {
|
||||
const next = this.queue.shift();
|
||||
if (!next) return;
|
||||
void next.run();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
/**
|
||||
* Pre-launch cleanup for stray skill symlinks under OpenClaw skill roots.
|
||||
*
|
||||
* Background: since openclaw commit 253e159700 ("fix: harden workspace skill
|
||||
* path containment"), the Gateway rejects any candidate under a skills root
|
||||
* whose realpath escapes that root, logging a noisy
|
||||
* `Skipping escaped skill path outside its configured root.
|
||||
* reason=symlink-escape source=openclaw-managed ...`
|
||||
* warning per offending entry on every start.
|
||||
*
|
||||
* Common offenders are one-shot install scripts that drop symlinks into:
|
||||
* - ~/.openclaw/skills/<name> -> ~/.agents/skills/<name>
|
||||
* - ~/.openclaw/workspace/skills/<name> -> ~/.openclaw/workspace/.agents/skills/<name>
|
||||
* - ~/.openclaw/skills/<name> -> ~/workspace/<repo>/skills/<name>
|
||||
* The hardened loader rejects these because their realpath escapes the
|
||||
* configured managed root, so they are pure log noise — entries that the
|
||||
* loader can never accept from this root.
|
||||
*
|
||||
* This helper is invoked before each Gateway launch to remove those
|
||||
* specific symlinks. Scope is intentionally narrow:
|
||||
* - source dirs: ~/.openclaw/skills and ~/.openclaw/workspace/skills
|
||||
* - target dirs: anything outside the matching managed skills root
|
||||
* Symlinks whose realpath stays inside the same managed skills root are left
|
||||
* untouched.
|
||||
*
|
||||
* Removal uses fs.rmSync({ force: true, recursive: true }) rather than
|
||||
* fs.unlinkSync so that directory symlinks and Windows junctions (the form
|
||||
* that non-admin Windows installs end up creating) are deleted correctly.
|
||||
* unlinkSync raises EPERM on those on Windows, and rmSync without recursive
|
||||
* can reject directory symlinks on some platforms.
|
||||
*
|
||||
* This is a transitional workaround. Once openclaw/openclaw#59219 lands and
|
||||
* the loader stops rejecting managed-source symlinks whose realpath escapes
|
||||
* the managed root, this helper can be removed entirely.
|
||||
*/
|
||||
import {
|
||||
existsSync,
|
||||
lstatSync,
|
||||
readlinkSync,
|
||||
readdirSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
type Dirent,
|
||||
} from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { getOpenClawConfigDir, getOpenClawResolvedDir, getOpenClawSkillsDir } from '../utils/paths';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export interface CleanupOptions {
|
||||
/** Override for ~/.openclaw/skills (mainly for tests). */
|
||||
skillsDir?: string;
|
||||
/** Override for ~/.agents/skills (mainly for tests/log context). */
|
||||
agentsDir?: string;
|
||||
/** Override for ~/.openclaw/workspace/skills (mainly for tests). */
|
||||
workspaceSkillsDir?: string;
|
||||
/** Override for ~/.openclaw/workspace/.agents/skills (mainly for tests). */
|
||||
workspaceAgentsDir?: string;
|
||||
}
|
||||
|
||||
export interface CleanupResult {
|
||||
/** Symlink names that were unlinked from the skills dir. */
|
||||
removed: string[];
|
||||
/** Total number of symlink entries that were inspected. */
|
||||
examined: number;
|
||||
/** Cleanup operations that could not be completed and should be retried later. */
|
||||
failed?: number;
|
||||
}
|
||||
|
||||
export interface PluginRuntimeDepsCleanupOptions {
|
||||
/** Override for ~/.openclaw/plugin-runtime-deps (mainly for tests). */
|
||||
runtimeDepsDir?: string;
|
||||
/** Override for the current bundled OpenClaw package dir (mainly for tests). */
|
||||
currentOpenClawDir?: string;
|
||||
}
|
||||
|
||||
function defaultSkillsDir(): string {
|
||||
return getOpenClawSkillsDir();
|
||||
}
|
||||
|
||||
function recordCleanupFailure(result: CleanupResult): void {
|
||||
result.failed = (result.failed ?? 0) + 1;
|
||||
}
|
||||
|
||||
function defaultAgentsDir(): string {
|
||||
return path.join(homedir(), '.agents', 'skills');
|
||||
}
|
||||
|
||||
function defaultWorkspaceSkillsDir(): string {
|
||||
return path.join(getOpenClawConfigDir(), 'workspace', 'skills');
|
||||
}
|
||||
|
||||
function defaultWorkspaceAgentsDir(): string {
|
||||
return path.join(getOpenClawConfigDir(), 'workspace', '.agents', 'skills');
|
||||
}
|
||||
|
||||
function defaultPluginRuntimeDepsDir(): string {
|
||||
return path.join(getOpenClawConfigDir(), 'plugin-runtime-deps');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the agents skills directory to its real path. When the directory
|
||||
* itself does not exist yet (fresh install), fall back to realpath'ing its
|
||||
* parent and re-appending the basename so a `~/.agents -> /opt/agents`
|
||||
* indirection is still honored. As a final fallback returns the lexical
|
||||
* resolved path.
|
||||
*/
|
||||
function resolveAgentsRealRoot(agentsDir: string): string {
|
||||
if (existsSync(agentsDir)) {
|
||||
try {
|
||||
return realpathSync(agentsDir);
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
const parent = path.dirname(agentsDir);
|
||||
const tail = path.basename(agentsDir);
|
||||
if (parent && parent !== agentsDir && existsSync(parent)) {
|
||||
try {
|
||||
return path.join(realpathSync(parent), tail);
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
return path.resolve(agentsDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lower-case path strings on Win32 only so the `path.relative` byte-wise
|
||||
* comparison aligns with NTFS case-insensitive semantics. No-op elsewhere.
|
||||
*/
|
||||
function normalizeForCompare(p: string): string {
|
||||
return process.platform === 'win32' ? p.toLowerCase() : p;
|
||||
}
|
||||
|
||||
function isInside(parent: string, child: string): boolean {
|
||||
const rel = path.relative(normalizeForCompare(parent), normalizeForCompare(child));
|
||||
if (rel === '') return true;
|
||||
return !rel.startsWith('..') && !path.isAbsolute(rel);
|
||||
}
|
||||
|
||||
function resolveSymlinkTarget(linkPath: string): string | null {
|
||||
try {
|
||||
const target = readlinkSync(linkPath);
|
||||
return path.resolve(path.dirname(linkPath), target);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function looksLikeOpenClawPackagePath(candidate: string): boolean {
|
||||
const normalized = candidate.replace(/\\/g, '/');
|
||||
return /\/node_modules(?:\/\.pnpm\/[^/]+\/node_modules)?\/openclaw(?:\/|$)/.test(normalized);
|
||||
}
|
||||
|
||||
function resolveCurrentOpenClawRoots(currentOpenClawDir: string): string[] {
|
||||
const roots = new Set<string>([path.resolve(currentOpenClawDir)]);
|
||||
try {
|
||||
roots.add(realpathSync(currentOpenClawDir));
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return Array.from(roots);
|
||||
}
|
||||
|
||||
export function cleanupAgentsSymlinkedSkills(opts: CleanupOptions = {}): CleanupResult {
|
||||
const hasMainOverrides = opts.skillsDir !== undefined || opts.agentsDir !== undefined;
|
||||
const hasWorkspaceOverrides =
|
||||
opts.workspaceSkillsDir !== undefined || opts.workspaceAgentsDir !== undefined;
|
||||
const roots = [
|
||||
{
|
||||
skillsDir: opts.skillsDir ?? defaultSkillsDir(),
|
||||
agentsDir: opts.agentsDir ?? defaultAgentsDir(),
|
||||
},
|
||||
];
|
||||
|
||||
if (!hasMainOverrides || hasWorkspaceOverrides) {
|
||||
roots.push({
|
||||
skillsDir: opts.workspaceSkillsDir ?? defaultWorkspaceSkillsDir(),
|
||||
agentsDir: opts.workspaceAgentsDir ?? defaultWorkspaceAgentsDir(),
|
||||
});
|
||||
}
|
||||
|
||||
const result: CleanupResult = { removed: [], examined: 0 };
|
||||
const seenRoots = new Set<string>();
|
||||
|
||||
for (const root of roots) {
|
||||
const rootKey = `${path.resolve(root.skillsDir)}\0${path.resolve(root.agentsDir)}`;
|
||||
if (seenRoots.has(rootKey)) continue;
|
||||
seenRoots.add(rootKey);
|
||||
|
||||
const rootResult = cleanupSkillsDir(root.skillsDir, root.agentsDir);
|
||||
result.removed.push(...rootResult.removed);
|
||||
result.examined += rootResult.examined;
|
||||
if (rootResult.failed) {
|
||||
result.failed = (result.failed ?? 0) + rootResult.failed;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove stale OpenClaw plugin runtime dependency cache roots.
|
||||
*
|
||||
* OpenClaw can materialize `~/.openclaw/plugin-runtime-deps/openclaw-*` as a
|
||||
* symlink tree back into the package's `dist` files. After app upgrades or
|
||||
* worktree switches those symlinks can point at an old `node_modules/openclaw`
|
||||
* path. The Gateway may then spend a long time synchronously opening/copying
|
||||
* old runtime files during plugin setup, which blocks RPC readiness.
|
||||
*
|
||||
* Scope is intentionally narrow: only immediate cache roots named `openclaw-*`
|
||||
* are removed, and only when a symlink inside points at an OpenClaw package
|
||||
* path outside the current bundled package. The cache is regenerated by
|
||||
* OpenClaw on demand.
|
||||
*/
|
||||
export function cleanupStalePluginRuntimeDeps(
|
||||
opts: PluginRuntimeDepsCleanupOptions = {},
|
||||
): CleanupResult {
|
||||
const runtimeDepsDir = opts.runtimeDepsDir ?? defaultPluginRuntimeDepsDir();
|
||||
const currentRoots = resolveCurrentOpenClawRoots(opts.currentOpenClawDir ?? getOpenClawResolvedDir());
|
||||
const result: CleanupResult = { removed: [], examined: 0 };
|
||||
|
||||
if (!existsSync(runtimeDepsDir)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = readdirSync(runtimeDepsDir, { withFileTypes: true, encoding: 'utf8' });
|
||||
} catch (err) {
|
||||
logger.warn(`[plugin-runtime-deps-cleanup] Failed to list ${runtimeDepsDir}:`, err);
|
||||
recordCleanupFailure(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || !entry.name.startsWith('openclaw-')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const cacheRoot = path.join(runtimeDepsDir, entry.name);
|
||||
const scan = scanRuntimeDepsRootForStaleOpenClawSymlink(cacheRoot, currentRoots);
|
||||
result.examined += scan.examined;
|
||||
if (!scan.stale) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
rmSync(cacheRoot, { force: true, recursive: true });
|
||||
result.removed.push(entry.name);
|
||||
} catch (err) {
|
||||
logger.warn(`[plugin-runtime-deps-cleanup] Failed to remove ${cacheRoot}:`, err);
|
||||
recordCleanupFailure(result);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.removed.length > 0) {
|
||||
logger.info(
|
||||
`[plugin-runtime-deps-cleanup] Removed ${result.removed.length} stale OpenClaw runtime cache root(s): ` +
|
||||
result.removed.join(', '),
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function scanRuntimeDepsRootForStaleOpenClawSymlink(
|
||||
cacheRoot: string,
|
||||
currentOpenClawRoots: string[],
|
||||
): { stale: boolean; examined: number } {
|
||||
const stack = [cacheRoot];
|
||||
let examined = 0;
|
||||
const maxEntries = 5000;
|
||||
|
||||
while (stack.length > 0 && examined < maxEntries) {
|
||||
const dir = stack.pop()!;
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = readdirSync(dir, { withFileTypes: true, encoding: 'utf8' });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (examined >= maxEntries) break;
|
||||
const entryPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
stack.push(entryPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
let isSymlink = entry.isSymbolicLink();
|
||||
if (!isSymlink) {
|
||||
try {
|
||||
isSymlink = lstatSync(entryPath).isSymbolicLink();
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!isSymlink) continue;
|
||||
|
||||
examined++;
|
||||
const target = resolveSymlinkTarget(entryPath);
|
||||
if (!target || !looksLikeOpenClawPackagePath(target)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pointsAtCurrentOpenClaw = currentOpenClawRoots.some((root) => isInside(root, target));
|
||||
if (!pointsAtCurrentOpenClaw) {
|
||||
return { stale: true, examined };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { stale: false, examined };
|
||||
}
|
||||
|
||||
function cleanupSkillsDir(skillsDir: string, agentsDir: string): CleanupResult {
|
||||
const result: CleanupResult = { removed: [], examined: 0 };
|
||||
if (!existsSync(skillsDir)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = readdirSync(skillsDir, { withFileTypes: true, encoding: 'utf8' });
|
||||
} catch (err) {
|
||||
logger.warn(`[skills-cleanup] Failed to list ${skillsDir}:`, err);
|
||||
recordCleanupFailure(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
const agentsRealRoot = resolveAgentsRealRoot(agentsDir);
|
||||
const skillsRealRoot = resolveAgentsRealRoot(skillsDir);
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(skillsDir, entry.name);
|
||||
|
||||
let isSymlink = entry.isSymbolicLink();
|
||||
if (!isSymlink) {
|
||||
try {
|
||||
isSymlink = lstatSync(entryPath).isSymbolicLink();
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!isSymlink) continue;
|
||||
|
||||
result.examined++;
|
||||
|
||||
let realTarget: string;
|
||||
try {
|
||||
realTarget = realpathSync(entryPath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isInside(skillsRealRoot, realTarget)) continue;
|
||||
|
||||
try {
|
||||
// rmSync handles file symlinks, directory symlinks, and Windows
|
||||
// junctions uniformly. unlinkSync would raise EPERM on directory
|
||||
// symlinks/junctions on Windows.
|
||||
rmSync(entryPath, { force: true, recursive: true });
|
||||
result.removed.push(entry.name);
|
||||
} catch (err) {
|
||||
logger.warn(`[skills-cleanup] Failed to remove ${entryPath}:`, err);
|
||||
recordCleanupFailure(result);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.removed.length > 0) {
|
||||
logger.info(
|
||||
`[skills-cleanup] Removed ${result.removed.length} stray skill symlink(s) ` +
|
||||
`under ${skillsDir} that escaped managed root ${skillsRealRoot} ` +
|
||||
`(workaround for openclaw/openclaw#59219): ` +
|
||||
result.removed.join(', '),
|
||||
);
|
||||
} else if (result.examined > 0) {
|
||||
logger.debug(
|
||||
`[skills-cleanup] Examined ${result.examined} symlink(s) under ${skillsDir}; ` +
|
||||
`none escaped managed root (agents context: ${agentsRealRoot})`,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { logger } from '../utils/logger';
|
||||
import { LifecycleSupersededError } from './lifecycle-controller';
|
||||
import { getGatewayStartupRecoveryAction } from './startup-recovery';
|
||||
import { connectGatewayWithStartupRetry, getGatewayStartupRecoveryAction } from './startup-recovery';
|
||||
|
||||
export interface ExistingGatewayInfo {
|
||||
port: number;
|
||||
@@ -9,13 +9,15 @@ export interface ExistingGatewayInfo {
|
||||
|
||||
type StartupHooks = {
|
||||
port: number;
|
||||
ownedPid?: number;
|
||||
ownedPid?: never; // Removed: pid is now read dynamically in findExistingGateway to avoid stale-snapshot bug
|
||||
shouldWaitForPortFree: boolean;
|
||||
maxStartAttempts?: number;
|
||||
/** Returns true when the manager still owns a living Gateway process (e.g. after a code-1012 in-process restart). */
|
||||
hasOwnedProcess: () => boolean;
|
||||
resetStartupStderrLines: () => void;
|
||||
getStartupStderrLines: () => string[];
|
||||
assertLifecycle: (phase: string) => void;
|
||||
findExistingGateway: (port: number, ownedPid?: number) => Promise<ExistingGatewayInfo | null>;
|
||||
findExistingGateway: (port: number) => Promise<ExistingGatewayInfo | null>;
|
||||
connect: (port: number, externalToken?: string) => Promise<void>;
|
||||
onConnectedToExistingGateway: () => void;
|
||||
waitForPortFree: (port: number) => Promise<void>;
|
||||
@@ -27,6 +29,22 @@ type StartupHooks = {
|
||||
delay: (ms: number) => Promise<void>;
|
||||
};
|
||||
|
||||
async function connectWithStartupRetry(
|
||||
hooks: StartupHooks,
|
||||
port: number,
|
||||
externalToken?: string,
|
||||
): Promise<void> {
|
||||
await connectGatewayWithStartupRetry({
|
||||
connect: hooks.connect,
|
||||
port,
|
||||
externalToken,
|
||||
delay: hooks.delay,
|
||||
beforeAttempt: () => hooks.assertLifecycle('start/connect-retry'),
|
||||
logWarn: (message) => logger.warn(message),
|
||||
logInfo: (message) => logger.info(message),
|
||||
});
|
||||
}
|
||||
|
||||
export async function runGatewayStartupSequence(hooks: StartupHooks): Promise<void> {
|
||||
let configRepairAttempted = false;
|
||||
let startAttempts = 0;
|
||||
@@ -39,16 +57,32 @@ export async function runGatewayStartupSequence(hooks: StartupHooks): Promise<vo
|
||||
|
||||
try {
|
||||
logger.debug('Checking for existing Gateway...');
|
||||
const existing = await hooks.findExistingGateway(hooks.port, hooks.ownedPid);
|
||||
const existing = await hooks.findExistingGateway(hooks.port);
|
||||
hooks.assertLifecycle('start/find-existing');
|
||||
if (existing) {
|
||||
logger.debug(`Found existing Gateway on port ${existing.port}`);
|
||||
await hooks.connect(existing.port, existing.externalToken);
|
||||
await connectWithStartupRetry(hooks, existing.port, existing.externalToken);
|
||||
hooks.assertLifecycle('start/connect-existing');
|
||||
hooks.onConnectedToExistingGateway();
|
||||
return;
|
||||
}
|
||||
|
||||
// When the Gateway did an in-process restart (WS close 1012), the
|
||||
// UtilityProcess is still alive but its WS server may be mid-rebuild,
|
||||
// so findExistingGateway's quick probe returns null. Rather than
|
||||
// waiting for the port to free (it never will — the process holds it)
|
||||
// and then spawning a duplicate, wait for the existing process to
|
||||
// become ready and reconnect to it.
|
||||
if (hooks.hasOwnedProcess()) {
|
||||
logger.info('Owned Gateway process still alive (likely in-process restart); waiting for it to become ready');
|
||||
await hooks.waitForReady(hooks.port);
|
||||
hooks.assertLifecycle('start/wait-ready-owned');
|
||||
await connectWithStartupRetry(hooks, hooks.port);
|
||||
hooks.assertLifecycle('start/connect-owned');
|
||||
hooks.onConnectedToExistingGateway();
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug('No existing Gateway found, starting new process...');
|
||||
|
||||
if (hooks.shouldWaitForPortFree) {
|
||||
@@ -62,7 +96,7 @@ export async function runGatewayStartupSequence(hooks: StartupHooks): Promise<vo
|
||||
await hooks.waitForReady(hooks.port);
|
||||
hooks.assertLifecycle('start/wait-ready');
|
||||
|
||||
await hooks.connect(hooks.port);
|
||||
await connectWithStartupRetry(hooks, hooks.port);
|
||||
hooks.assertLifecycle('start/connect');
|
||||
|
||||
hooks.onConnectedToManagedGateway();
|
||||
|
||||
@@ -8,18 +8,40 @@
|
||||
const INVALID_CONFIG_PATTERNS: RegExp[] = [
|
||||
/\binvalid config\b/i,
|
||||
/\bconfig invalid\b/i,
|
||||
/\bfatal configuration error\b/i,
|
||||
/\bunrecognized key\b/i,
|
||||
/\bstartup migration(?:s)?\b.*\b(?:blocked|failed|did not complete cleanly)\b/i,
|
||||
/\bmigration\b.*\bopenclaw doctor --fix\b/i,
|
||||
/\brun:\s*openclaw doctor --fix\b/i,
|
||||
];
|
||||
|
||||
const FATAL_RUNTIME_PATTERNS: RegExp[] = [
|
||||
/\bNode(?:\.js)?\b.*\boutside the supported range\b/i,
|
||||
/\buses SQLite\b.*\bnot WAL-reset-safe\b/i,
|
||||
/\bSQLite\b.*\bWAL-reset-safe runtime required\b/i,
|
||||
/\bInstall Node 24\.15\+.*\bNode 22\.22\.3\+\b/i,
|
||||
];
|
||||
|
||||
const STARTUP_MIGRATION_LOCK_PATTERNS: RegExp[] = [
|
||||
/\bstartup migrations? (?:is|are) already running\b/i,
|
||||
/\bretry after the other gateway finishes\b/i,
|
||||
];
|
||||
|
||||
const TRANSIENT_START_ERROR_PATTERNS: RegExp[] = [
|
||||
/WebSocket closed before handshake/i,
|
||||
/ECONNREFUSED/i,
|
||||
/Gateway process exited before becoming ready/i,
|
||||
/Timed out waiting for connect\.challenge/i,
|
||||
/Connect handshake timeout/i,
|
||||
// OpenClaw can emit connect.challenge before the connect RPC is accepted.
|
||||
/gateway starting/i,
|
||||
// Port occupied after orphan kill: transient, worth retrying with backoff
|
||||
/Port \d+ still occupied after \d+ms/i,
|
||||
];
|
||||
|
||||
/** Backoff between connect() attempts when the Gateway rejects with "still starting". */
|
||||
export const GATEWAY_CONNECT_STARTUP_RETRY_DELAYS_MS = [500, 1_000, 2_000, 4_000, 8_000, 8_000] as const;
|
||||
|
||||
function normalizeLogLine(value: string): string {
|
||||
return value.trim();
|
||||
}
|
||||
@@ -54,6 +76,33 @@ export function hasInvalidConfigFailureSignal(
|
||||
return isInvalidConfigSignal(errorText);
|
||||
}
|
||||
|
||||
function startupFailureCandidates(startupError: unknown, startupStderrLines: string[]): string[] {
|
||||
return [
|
||||
...startupStderrLines,
|
||||
startupError instanceof Error
|
||||
? `${startupError.name}: ${startupError.message}`
|
||||
: String(startupError ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
/** Returns true for OpenClaw runtime/SQLite failures that doctor cannot repair. */
|
||||
export function hasFatalRuntimeFailureSignal(
|
||||
startupError: unknown,
|
||||
startupStderrLines: string[],
|
||||
): boolean {
|
||||
return startupFailureCandidates(startupError, startupStderrLines)
|
||||
.some((text) => FATAL_RUNTIME_PATTERNS.some((pattern) => pattern.test(text)));
|
||||
}
|
||||
|
||||
/** Returns true while another/stale OpenClaw startup migration lease is active. */
|
||||
export function hasStartupMigrationLockSignal(
|
||||
startupError: unknown,
|
||||
startupStderrLines: string[],
|
||||
): boolean {
|
||||
return startupFailureCandidates(startupError, startupStderrLines)
|
||||
.some((text) => STARTUP_MIGRATION_LOCK_PATTERNS.some((pattern) => pattern.test(text)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry guard for one-time config repair during a single startup flow.
|
||||
*/
|
||||
@@ -73,6 +122,53 @@ export function isTransientGatewayStartError(error: unknown): boolean {
|
||||
return TRANSIENT_START_ERROR_PATTERNS.some((pattern) => pattern.test(errorText));
|
||||
}
|
||||
|
||||
export function isGatewayStillStartingError(error: unknown): boolean {
|
||||
const errorText = error instanceof Error
|
||||
? error.message
|
||||
: String(error ?? '');
|
||||
return /gateway starting/i.test(errorText);
|
||||
}
|
||||
|
||||
export async function connectGatewayWithStartupRetry(options: {
|
||||
connect: (port: number, externalToken?: string) => Promise<void>;
|
||||
port: number;
|
||||
externalToken?: string;
|
||||
delay: (ms: number) => Promise<void>;
|
||||
retryDelaysMs?: readonly number[];
|
||||
beforeAttempt?: () => void;
|
||||
logWarn?: (message: string) => void;
|
||||
logInfo?: (message: string) => void;
|
||||
}): Promise<void> {
|
||||
const retryDelaysMs = options.retryDelaysMs ?? GATEWAY_CONNECT_STARTUP_RETRY_DELAYS_MS;
|
||||
const logWarn = options.logWarn ?? (() => {});
|
||||
const logInfo = options.logInfo ?? (() => {});
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 0; attempt <= retryDelaysMs.length; attempt += 1) {
|
||||
options.beforeAttempt?.();
|
||||
try {
|
||||
await options.connect(options.port, options.externalToken);
|
||||
if (attempt > 0) {
|
||||
logInfo(`Gateway connect succeeded after ${attempt + 1} attempt(s)`);
|
||||
}
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (!isGatewayStillStartingError(error) || attempt >= retryDelaysMs.length) {
|
||||
throw error;
|
||||
}
|
||||
const delayMs = retryDelaysMs[attempt] ?? retryDelaysMs[retryDelaysMs.length - 1]!;
|
||||
logWarn(
|
||||
`Gateway connect rejected while still starting (${String(error)}); `
|
||||
+ `retrying in ${delayMs}ms (${attempt + 1}/${retryDelaysMs.length})`,
|
||||
);
|
||||
await options.delay(delayMs);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError instanceof Error ? lastError : new Error(String(lastError ?? 'Gateway connect failed'));
|
||||
}
|
||||
|
||||
export type GatewayStartupRecoveryAction = 'repair' | 'retry' | 'fail';
|
||||
|
||||
export function getGatewayStartupRecoveryAction(options: {
|
||||
@@ -82,12 +178,18 @@ export function getGatewayStartupRecoveryAction(options: {
|
||||
attempt: number;
|
||||
maxAttempts: number;
|
||||
}): GatewayStartupRecoveryAction {
|
||||
if (shouldAttemptConfigAutoRepair(
|
||||
options.startupError,
|
||||
options.startupStderrLines,
|
||||
options.configRepairAttempted,
|
||||
)) {
|
||||
return 'repair';
|
||||
if (
|
||||
hasFatalRuntimeFailureSignal(options.startupError, options.startupStderrLines)
|
||||
|| hasStartupMigrationLockSignal(options.startupError, options.startupStderrLines)
|
||||
) {
|
||||
return 'fail';
|
||||
}
|
||||
|
||||
if (hasInvalidConfigFailureSignal(options.startupError, options.startupStderrLines)) {
|
||||
// One doctor pass is the only automated repair. If the same migration or
|
||||
// config failure remains afterward, stop instead of treating the generic
|
||||
// process-exited error as transient.
|
||||
return options.configRepairAttempted ? 'fail' : 'repair';
|
||||
}
|
||||
|
||||
if (options.attempt < options.maxAttempts && isTransientGatewayStartError(options.startupError)) {
|
||||
|
||||
@@ -1,31 +1,137 @@
|
||||
export type GatewayStderrClassification = {
|
||||
level: 'drop' | 'debug' | 'warn';
|
||||
level: 'drop' | 'debug' | 'info' | 'warn';
|
||||
normalized: string;
|
||||
};
|
||||
|
||||
export type GatewayStartupTraceStage = {
|
||||
name: string;
|
||||
durationMs: number;
|
||||
totalMs?: number;
|
||||
};
|
||||
|
||||
export type GatewayStartupTraceSummary = {
|
||||
stageCount: number;
|
||||
lastStage?: string;
|
||||
traceTotalMs?: number;
|
||||
slowestStage?: string;
|
||||
slowestStageMs?: number;
|
||||
};
|
||||
|
||||
export const GATEWAY_STARTUP_SLOW_STAGE_MS = 10_000;
|
||||
export const GATEWAY_STARTUP_SLOW_TOTAL_MS = 30_000;
|
||||
|
||||
const MAX_STDERR_LINES = 120;
|
||||
const ANSI_ESCAPE_PATTERN = new RegExp(String.raw`\u001B\[[0-?]*[ -/]*[@-~]`, 'g');
|
||||
const STARTUP_TRACE_PATTERN = /startup trace:\s+([^\s]+)\s+(\d+(?:\.\d+)?)ms(?:\s+total=(\d+(?:\.\d+)?)ms)?/i;
|
||||
|
||||
export function parseGatewayStartupTraceStage(message: string): GatewayStartupTraceStage | null {
|
||||
const match = STARTUP_TRACE_PATTERN.exec(message.replace(ANSI_ESCAPE_PATTERN, ''));
|
||||
if (!match) return null;
|
||||
|
||||
const durationMs = Number(match[2]);
|
||||
const totalMs = match[3] === undefined ? undefined : Number(match[3]);
|
||||
if (!Number.isFinite(durationMs) || (totalMs !== undefined && !Number.isFinite(totalMs))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
name: match[1]!,
|
||||
durationMs,
|
||||
...(totalMs === undefined ? {} : { totalMs }),
|
||||
};
|
||||
}
|
||||
|
||||
export class GatewayStartupTraceCollector {
|
||||
private stageCount = 0;
|
||||
private lastStage: GatewayStartupTraceStage | null = null;
|
||||
private slowestStage: GatewayStartupTraceStage | null = null;
|
||||
private maxTraceTotalMs: number | undefined;
|
||||
|
||||
reset(): void {
|
||||
this.stageCount = 0;
|
||||
this.lastStage = null;
|
||||
this.slowestStage = null;
|
||||
this.maxTraceTotalMs = undefined;
|
||||
}
|
||||
|
||||
record(message: string): GatewayStartupTraceStage | null {
|
||||
const stage = parseGatewayStartupTraceStage(message);
|
||||
if (!stage) return null;
|
||||
|
||||
this.stageCount += 1;
|
||||
this.lastStage = stage;
|
||||
if (!this.slowestStage || stage.durationMs > this.slowestStage.durationMs) {
|
||||
this.slowestStage = stage;
|
||||
}
|
||||
if (stage.totalMs !== undefined) {
|
||||
this.maxTraceTotalMs = Math.max(this.maxTraceTotalMs ?? 0, stage.totalMs);
|
||||
}
|
||||
return stage;
|
||||
}
|
||||
|
||||
getSummary(): GatewayStartupTraceSummary {
|
||||
return {
|
||||
stageCount: this.stageCount,
|
||||
...(this.lastStage ? { lastStage: this.lastStage.name } : {}),
|
||||
...(this.maxTraceTotalMs === undefined ? {} : { traceTotalMs: this.maxTraceTotalMs }),
|
||||
...(this.slowestStage ? {
|
||||
slowestStage: this.slowestStage.name,
|
||||
slowestStageMs: this.slowestStage.durationMs,
|
||||
} : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function classifyGatewayStderrMessage(message: string): GatewayStderrClassification {
|
||||
const msg = message.trim();
|
||||
if (!msg) {
|
||||
return { level: 'drop', normalized: msg };
|
||||
}
|
||||
const plain = msg.replace(ANSI_ESCAPE_PATTERN, '');
|
||||
|
||||
// OpenClaw startup timing traces are expected diagnostics, not failures.
|
||||
if (plain.includes('startup trace:')) {
|
||||
return { level: 'info', normalized: msg };
|
||||
}
|
||||
|
||||
// Known noisy lines that are not actionable for Gateway lifecycle debugging.
|
||||
if (msg.includes('openclaw-control-ui') && msg.includes('token_mismatch')) {
|
||||
if (plain.includes('openclaw-control-ui') && plain.includes('token_mismatch')) {
|
||||
return { level: 'drop', normalized: msg };
|
||||
}
|
||||
if (msg.includes('closed before connect') && msg.includes('token mismatch')) {
|
||||
if (plain.includes('closed before connect') && plain.includes('token mismatch')) {
|
||||
return { level: 'drop', normalized: msg };
|
||||
}
|
||||
if (plain.includes('[ws] closed before connect') && plain.includes('code=1005')) {
|
||||
return { level: 'debug', normalized: msg };
|
||||
}
|
||||
if (
|
||||
plain.includes('[ws] closed before connect')
|
||||
&& plain.includes('code=1006')
|
||||
&& plain.includes('phase=ws_upgrade_started')
|
||||
&& plain.includes('ua=n/a')
|
||||
) {
|
||||
return { level: 'debug', normalized: msg };
|
||||
}
|
||||
if (plain.includes('security warning: dangerous config flags enabled')) {
|
||||
return { level: 'debug', normalized: msg };
|
||||
}
|
||||
|
||||
// Downgrade frequent non-fatal noise.
|
||||
if (msg.includes('ExperimentalWarning')) return { level: 'debug', normalized: msg };
|
||||
if (msg.includes('DeprecationWarning')) return { level: 'debug', normalized: msg };
|
||||
if (msg.includes('Debugger attached')) return { level: 'debug', normalized: msg };
|
||||
if (plain.includes('ExperimentalWarning')) return { level: 'debug', normalized: msg };
|
||||
if (plain.includes('DeprecationWarning')) return { level: 'debug', normalized: msg };
|
||||
if (plain.includes('Rename them by replacing the legacy prefix with OPENCLAW_')) {
|
||||
return { level: 'debug', normalized: msg };
|
||||
}
|
||||
if (plain.includes('--trace-deprecation') && plain.includes('show where the warning was created')) {
|
||||
return { level: 'debug', normalized: msg };
|
||||
}
|
||||
if (plain.includes('Debugger attached')) return { level: 'debug', normalized: msg };
|
||||
|
||||
// Gateway config warnings (e.g. stale plugin entries) are informational, not actionable.
|
||||
if (plain.includes('Config warnings:')) return { level: 'debug', normalized: msg };
|
||||
|
||||
// Electron restricts NODE_OPTIONS in packaged apps; this is expected and harmless.
|
||||
if (msg.includes('node: --require is not allowed in NODE_OPTIONS')) {
|
||||
if (plain.includes('node: --require is not allowed in NODE_OPTIONS')) {
|
||||
return { level: 'debug', normalized: msg };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { app, utilityProcess } from 'electron';
|
||||
import path from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import WebSocket from 'ws';
|
||||
import { getOpenClawDir, getOpenClawEntryPath } from '../utils/paths';
|
||||
import { getUvMirrorEnv } from '../utils/uv-env';
|
||||
import { isPythonReady, setupManagedPython } from '../utils/uv-setup';
|
||||
import { logger } from '../utils/logger';
|
||||
import { prependPathEntry } from '../utils/env-path';
|
||||
import { probeGatewayReady } from './ws-client';
|
||||
|
||||
export function warmupManagedPythonReadiness(): void {
|
||||
void isPythonReady().then((pythonReady) => {
|
||||
@@ -22,39 +22,58 @@ export function warmupManagedPythonReadiness(): void {
|
||||
}
|
||||
|
||||
export async function terminateOwnedGatewayProcess(child: Electron.UtilityProcess): Promise<void> {
|
||||
let exited = false;
|
||||
const terminateWindowsProcessTree = async (pid: number): Promise<void> => {
|
||||
const cp = await import('child_process');
|
||||
await new Promise<void>((resolve) => {
|
||||
cp.exec(`taskkill /F /PID ${pid} /T`, { timeout: 5000, windowsHide: true }, () => resolve());
|
||||
});
|
||||
};
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
let exited = false;
|
||||
|
||||
// Register a single exit listener before any kill attempt to avoid
|
||||
// the race where exit fires between two separate `once('exit')` calls.
|
||||
child.once('exit', () => {
|
||||
exited = true;
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
|
||||
const pid = child.pid;
|
||||
logger.info(`Sending kill to Gateway process (pid=${pid ?? 'unknown'})`);
|
||||
try {
|
||||
child.kill();
|
||||
} catch {
|
||||
// ignore if already exited
|
||||
|
||||
if (process.platform === 'win32' && pid) {
|
||||
void terminateWindowsProcessTree(pid).catch((err) => {
|
||||
logger.warn(`Windows process-tree kill failed for Gateway pid=${pid}:`, err);
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
child.kill();
|
||||
} catch {
|
||||
// ignore if already exited
|
||||
}
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (!exited) {
|
||||
logger.warn(`Gateway did not exit in time, force-killing (pid=${pid ?? 'unknown'})`);
|
||||
if (pid) {
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL');
|
||||
} catch {
|
||||
// ignore
|
||||
if (process.platform === 'win32') {
|
||||
void terminateWindowsProcessTree(pid).catch((err) => {
|
||||
logger.warn(`Forced Windows process-tree kill failed for Gateway pid=${pid}:`, err);
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
resolve();
|
||||
}, 5000);
|
||||
|
||||
child.once('exit', () => {
|
||||
clearTimeout(timeout);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -137,7 +156,8 @@ export async function waitForPortFree(port: number, timeoutMs = 30000): Promise<
|
||||
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
||||
}
|
||||
|
||||
logger.warn(`Port ${port} still occupied after ${timeoutMs}ms, proceeding anyway`);
|
||||
logger.error(`Port ${port} still occupied after ${timeoutMs}ms; aborting startup to avoid port conflict`);
|
||||
throw new Error(`Port ${port} still occupied after ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
async function getListeningProcessIds(port: number): Promise<string[]> {
|
||||
@@ -226,30 +246,17 @@ export async function findExistingGatewayProcess(options: {
|
||||
const pids = await getListeningProcessIds(port);
|
||||
if (pids.length > 0 && (!ownedPid || !pids.includes(String(ownedPid)))) {
|
||||
await terminateOrphanedProcessIds(port, pids);
|
||||
if (process.platform === 'win32') {
|
||||
await waitForPortFree(port, 10000);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Error checking for existing process on port:', err);
|
||||
}
|
||||
|
||||
return await new Promise<{ port: number; externalToken?: string } | null>((resolve) => {
|
||||
const testWs = new WebSocket(`ws://localhost:${port}/ws`);
|
||||
const timeout = setTimeout(() => {
|
||||
testWs.close();
|
||||
resolve(null);
|
||||
}, 2000);
|
||||
|
||||
testWs.on('open', () => {
|
||||
clearTimeout(timeout);
|
||||
testWs.close();
|
||||
resolve({ port });
|
||||
});
|
||||
|
||||
testWs.on('error', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
const ready = await probeGatewayReady(port, 5000);
|
||||
return ready ? { port } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,14 @@ import {
|
||||
signDevicePayload,
|
||||
} from '../utils/device-identity';
|
||||
import { logger } from '../utils/logger';
|
||||
import {
|
||||
isGatewayWsTraceEnabled,
|
||||
redactGatewayFrameForTrace,
|
||||
summarizeGatewayFrameForTrace,
|
||||
} from './ws-trace';
|
||||
|
||||
export const GATEWAY_CHALLENGE_TIMEOUT_MS = 10_000;
|
||||
export const GATEWAY_CONNECT_HANDSHAKE_TIMEOUT_MS = 20_000;
|
||||
|
||||
export async function probeGatewayReady(
|
||||
port: number,
|
||||
@@ -21,7 +29,10 @@ export async function probeGatewayReady(
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
try {
|
||||
testWs.close();
|
||||
// Use terminate() (TCP RST) instead of close() (WS close handshake)
|
||||
// to avoid leaving TIME_WAIT connections on Windows. These probe
|
||||
// WebSockets are short-lived and don't need a graceful close.
|
||||
testWs.terminate();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -96,6 +107,8 @@ export async function waitForGatewayReady(options: {
|
||||
throw new Error(`Gateway failed to start after ${retries} retries (port ${options.port})`);
|
||||
}
|
||||
|
||||
const GATEWAY_PROTOCOL_VERSION = 4;
|
||||
|
||||
export function buildGatewayConnectFrame(options: {
|
||||
challengeNonce: string;
|
||||
token: string;
|
||||
@@ -139,8 +152,8 @@ export function buildGatewayConnectFrame(options: {
|
||||
id: connectId,
|
||||
method: 'connect',
|
||||
params: {
|
||||
minProtocol: 3,
|
||||
maxProtocol: 3,
|
||||
minProtocol: GATEWAY_PROTOCOL_VERSION,
|
||||
maxProtocol: GATEWAY_PROTOCOL_VERSION,
|
||||
client: {
|
||||
id: clientId,
|
||||
displayName: 'ClawX',
|
||||
@@ -151,7 +164,7 @@ export function buildGatewayConnectFrame(options: {
|
||||
auth: {
|
||||
token: options.token,
|
||||
},
|
||||
caps: [],
|
||||
caps: ['tool-events'],
|
||||
role,
|
||||
scopes,
|
||||
device,
|
||||
@@ -168,9 +181,13 @@ export async function connectGatewaySocket(options: {
|
||||
getToken: () => Promise<string>;
|
||||
onHandshakeComplete: (ws: WebSocket) => void;
|
||||
onMessage: (message: unknown) => void;
|
||||
onCloseAfterHandshake: () => void;
|
||||
onCloseAfterHandshake: (code: number) => void;
|
||||
challengeTimeoutMs?: number;
|
||||
connectTimeoutMs?: number;
|
||||
}): Promise<WebSocket> {
|
||||
logger.debug(`Connecting Gateway WebSocket (ws://localhost:${options.port}/ws)`);
|
||||
const challengeTimeoutMs = options.challengeTimeoutMs ?? GATEWAY_CHALLENGE_TIMEOUT_MS;
|
||||
const connectTimeoutMs = options.connectTimeoutMs ?? GATEWAY_CONNECT_HANDSHAKE_TIMEOUT_MS;
|
||||
|
||||
return await new Promise<WebSocket>((resolve, reject) => {
|
||||
const wsUrl = `ws://localhost:${options.port}/ws`;
|
||||
@@ -211,6 +228,13 @@ export async function connectGatewaySocket(options: {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanupHandshakeRequest();
|
||||
if (!handshakeComplete) {
|
||||
try {
|
||||
ws.terminate();
|
||||
} catch {
|
||||
// ignore cleanup errors during failed startup handshakes
|
||||
}
|
||||
}
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
};
|
||||
|
||||
@@ -226,6 +250,12 @@ export async function connectGatewaySocket(options: {
|
||||
});
|
||||
connectId = connectPayload.connectId;
|
||||
|
||||
if (isGatewayWsTraceEnabled()) {
|
||||
logger.debug('[gateway-ws-trace] send', {
|
||||
summary: summarizeGatewayFrameForTrace(connectPayload.frame),
|
||||
frame: redactGatewayFrameForTrace(connectPayload.frame),
|
||||
});
|
||||
}
|
||||
ws.send(JSON.stringify(connectPayload.frame));
|
||||
|
||||
const requestTimeout = setTimeout(() => {
|
||||
@@ -234,7 +264,7 @@ export async function connectGatewaySocket(options: {
|
||||
ws.close();
|
||||
rejectOnce(new Error('Connect handshake timeout'));
|
||||
}
|
||||
}, 10000);
|
||||
}, connectTimeoutMs);
|
||||
handshakeTimeout = requestTimeout;
|
||||
|
||||
options.pendingRequests.set(connectId, {
|
||||
@@ -258,7 +288,7 @@ export async function connectGatewaySocket(options: {
|
||||
ws.close();
|
||||
rejectOnce(new Error('Timed out waiting for connect.challenge from Gateway'));
|
||||
}
|
||||
}, 10000);
|
||||
}, challengeTimeoutMs);
|
||||
|
||||
ws.on('open', () => {
|
||||
logger.debug('Gateway WebSocket opened, waiting for connect.challenge...');
|
||||
@@ -267,6 +297,12 @@ export async function connectGatewaySocket(options: {
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
if (isGatewayWsTraceEnabled()) {
|
||||
logger.debug('[gateway-ws-trace] recv', {
|
||||
summary: summarizeGatewayFrameForTrace(message),
|
||||
frame: redactGatewayFrameForTrace(message),
|
||||
});
|
||||
}
|
||||
if (
|
||||
!challengeReceived &&
|
||||
typeof message === 'object' && message !== null &&
|
||||
@@ -301,7 +337,7 @@ export async function connectGatewaySocket(options: {
|
||||
return;
|
||||
}
|
||||
cleanupHandshakeRequest();
|
||||
options.onCloseAfterHandshake();
|
||||
options.onCloseAfterHandshake(code);
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
const SECRET_KEYS = new Set([
|
||||
'token',
|
||||
'authorization',
|
||||
'apikey',
|
||||
'api_key',
|
||||
'signature',
|
||||
'cookie',
|
||||
'set-cookie',
|
||||
'accesstoken',
|
||||
'refreshtoken',
|
||||
]);
|
||||
|
||||
export function isGatewayWsTraceEnabled(): boolean {
|
||||
return process.env.CLAWX_GATEWAY_WS_TRACE === '1';
|
||||
}
|
||||
|
||||
export function redactGatewayFrameForTrace(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => redactGatewayFrameForTrace(item));
|
||||
}
|
||||
if (!value || typeof value !== 'object') {
|
||||
return value;
|
||||
}
|
||||
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
|
||||
const normalizedKey = key.toLowerCase();
|
||||
result[key] = SECRET_KEYS.has(normalizedKey)
|
||||
? '[redacted]'
|
||||
: redactGatewayFrameForTrace(item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function summarizeGatewayFrameForTrace(value: unknown): string {
|
||||
if (!value || typeof value !== 'object') return typeof value;
|
||||
const frame = value as Record<string, unknown>;
|
||||
if (frame.type === 'req') {
|
||||
return `req id=${String(frame.id ?? '-')} method=${String(frame.method ?? '-')}`;
|
||||
}
|
||||
if (frame.type === 'res') {
|
||||
return `res id=${String(frame.id ?? '-')} ok=${String(frame.ok ?? !frame.error)}`;
|
||||
}
|
||||
if (frame.type === 'event') {
|
||||
return `event ${String(frame.event ?? '-')}`;
|
||||
}
|
||||
if (typeof frame.method === 'string') {
|
||||
return `jsonrpc method=${frame.method}`;
|
||||
}
|
||||
return 'unknown gateway frame';
|
||||
}
|
||||
+529
-159
@@ -2,13 +2,17 @@
|
||||
* Electron Main Process Entry
|
||||
* Manages window creation, system tray, and IPC handlers
|
||||
*/
|
||||
import { app, BrowserWindow, nativeImage, session, shell } from 'electron';
|
||||
import type { Server } from 'node:http';
|
||||
import { app, BrowserWindow, nativeImage, session, shell, type Session } from 'electron';
|
||||
import { join } from 'path';
|
||||
import { GatewayManager } from '../gateway/manager';
|
||||
import { RuntimeManager } from '../runtime/manager';
|
||||
import { OpenClawRuntimeProvider } from '../runtime/openclaw-provider';
|
||||
import { CcConnectRuntimeProvider } from '../runtime/cc-connect-provider';
|
||||
import { registerIpcHandlers } from './ipc-handlers';
|
||||
import { HostApiRegistry } from './ipc/host-invoke';
|
||||
import { createTray } from './tray';
|
||||
import { createMenu } from './menu';
|
||||
import { registerZoomShortcuts } from './zoom-shortcuts';
|
||||
|
||||
import { appUpdater, registerUpdateHandlers } from './updater';
|
||||
import { logger } from '../utils/logger';
|
||||
@@ -16,19 +20,58 @@ import { warmupNetworkOptimization } from '../utils/uv-env';
|
||||
import { initTelemetry } from '../utils/telemetry';
|
||||
|
||||
import { ClawHubService } from '../gateway/clawhub';
|
||||
import { ensureClawXContext, repairClawXOnlyBootstrapFiles } from '../utils/openclaw-workspace';
|
||||
import { extensionRegistry } from '../extensions/registry';
|
||||
import { loadExtensionsFromManifest } from '../extensions/loader';
|
||||
import { registerAllBuiltinExtensions } from '../extensions/builtin';
|
||||
import { loadExternalMainExtensions } from '../extensions/_ext-bridge.generated';
|
||||
import {
|
||||
ensureClawXContext,
|
||||
ensureClawXDefaultIdentity,
|
||||
repairClawXOnlyBootstrapFiles,
|
||||
} from '../utils/openclaw-workspace';
|
||||
import { autoInstallCliIfNeeded, generateCompletionCache, installCompletionToProfile } from '../utils/openclaw-cli';
|
||||
import { isQuitting, setQuitting } from './app-state';
|
||||
import { getMacTrafficLightPosition, syncMacTrafficLightPosition } from './traffic-light-layout';
|
||||
import { getSetting } from '../utils/store';
|
||||
import { applyProxySettings } from './proxy';
|
||||
import { syncLaunchAtStartupSettingFromStore } from './launch-at-startup';
|
||||
import { getSetting } from '../utils/store';
|
||||
import { ensureBuiltinSkillsInstalled, ensurePreinstalledSkillsInstalled } from '../utils/skill-config';
|
||||
import { startHostApiServer } from '../api/server';
|
||||
import { HostEventBus } from '../api/event-bus';
|
||||
import { WebBrowserGuestRegistry, installWebBrowserGuestPolicy } from './web-browser-policy';
|
||||
import { configureWebBrowserSession } from './web-browser-session';
|
||||
import {
|
||||
clearPendingSecondInstanceFocus,
|
||||
consumeMainWindowReady,
|
||||
createMainWindowFocusState,
|
||||
requestSecondInstanceFocus,
|
||||
} from './main-window-focus';
|
||||
import {
|
||||
createQuitLifecycleState,
|
||||
markQuitCleanupCompleted,
|
||||
requestQuitLifecycleAction,
|
||||
} from './quit-lifecycle';
|
||||
import { createSignalQuitHandler } from './signal-quit';
|
||||
import { acquireProcessInstanceFileLock } from './process-instance-lock';
|
||||
import { ensureBuiltinSkillsInstalled, ensurePreinstalledSkillsInstalled, trimBundledOpenClawSkillsAndConfigs } from '../utils/skill-config';
|
||||
|
||||
import { deviceOAuthManager } from '../utils/device-oauth';
|
||||
import { browserOAuthManager } from '../utils/browser-oauth';
|
||||
import { whatsAppLoginManager } from '../utils/whatsapp-login';
|
||||
import { syncAllProviderAuthToRuntime } from '../services/providers/provider-runtime-sync';
|
||||
import { getClawXDataLayout, initializeClawXDataLayout } from '../utils/clawx-data-layout';
|
||||
import { migrateLegacyProviderSecretsToVault } from '../services/secrets/secret-store';
|
||||
import { migrateLegacyClawXData } from '../utils/clawx-data-migration';
|
||||
|
||||
const WINDOWS_APP_USER_MODEL_ID = 'app.clawx.desktop';
|
||||
const isE2EMode = process.env.CLAWX_E2E === '1';
|
||||
const enforceWriterLockInE2E = process.env.CLAWX_E2E_ENFORCE_WRITER_LOCK === '1';
|
||||
const requestedRemoteDebuggingPort = process.env.CLAWX_REMOTE_DEBUGGING_PORT?.trim();
|
||||
const legacyElectronUserDataDir = app.getPath('userData');
|
||||
const clawXDataLayout = getClawXDataLayout();
|
||||
|
||||
if (requestedRemoteDebuggingPort) {
|
||||
app.commandLine.appendSwitch('remote-debugging-port', requestedRemoteDebuggingPort);
|
||||
}
|
||||
|
||||
app.setPath('userData', clawXDataLayout.electronUserDataDir);
|
||||
|
||||
// Disable GPU hardware acceleration globally for maximum stability across
|
||||
// all GPU configurations (no GPU, integrated, discrete).
|
||||
@@ -51,24 +94,82 @@ app.disableHardwareAcceleration();
|
||||
// on X11 it supplements the StartupWMClass matching.
|
||||
// Must be called before app.whenReady() / before any window is created.
|
||||
if (process.platform === 'linux') {
|
||||
app.setDesktopName('clawx.desktop');
|
||||
const linuxApp = app as typeof app & { setDesktopName?: (desktopName: string) => void };
|
||||
linuxApp.setDesktopName?.('clawx.desktop');
|
||||
}
|
||||
|
||||
// Prevent multiple instances of the app from running simultaneously.
|
||||
// Without this, two instances each spawn their own gateway process on the
|
||||
// same port, then each treats the other's gateway as "orphaned" and kills
|
||||
// it — creating an infinite kill/restart loop on Windows.
|
||||
const gotTheLock = app.requestSingleInstanceLock();
|
||||
if (!gotTheLock) {
|
||||
app.quit();
|
||||
// The losing process must exit immediately so it never reaches Gateway startup.
|
||||
const gotElectronLock = isE2EMode ? true : app.requestSingleInstanceLock();
|
||||
if (!gotElectronLock) {
|
||||
console.info('[ClawX] Another instance already holds the single-instance lock; exiting duplicate process');
|
||||
app.exit(0);
|
||||
}
|
||||
let releaseProcessInstanceFileLock: () => void = () => {};
|
||||
let gotFileLock = true;
|
||||
if (gotElectronLock && (!isE2EMode || enforceWriterLockInE2E)) {
|
||||
try {
|
||||
const fileLock = acquireProcessInstanceFileLock({
|
||||
userDataDir: clawXDataLayout.locksDir,
|
||||
lockName: 'writer',
|
||||
lockPath: clawXDataLayout.writerLockPath,
|
||||
metadata: {
|
||||
appVersion: app.getVersion(),
|
||||
channel: process.env.CLAWX_RELEASE_CHANNEL?.trim() || (app.isPackaged ? 'stable' : 'dev'),
|
||||
executable: process.execPath,
|
||||
},
|
||||
});
|
||||
gotFileLock = fileLock.acquired;
|
||||
releaseProcessInstanceFileLock = fileLock.release;
|
||||
if (!fileLock.acquired) {
|
||||
const ownerDescriptor = fileLock.ownerPid
|
||||
? `${fileLock.ownerFormat ?? 'legacy'} pid=${fileLock.ownerPid}`
|
||||
: fileLock.ownerFormat === 'unknown'
|
||||
? 'unknown lock format/content'
|
||||
: 'unknown owner';
|
||||
console.info(
|
||||
`[ClawX] Another instance already holds process lock (${fileLock.lockPath}, ${ownerDescriptor}); exiting duplicate process`,
|
||||
);
|
||||
app.exit(0);
|
||||
}
|
||||
} catch (error) {
|
||||
gotFileLock = false;
|
||||
console.error('[ClawX] Failed to acquire process instance file lock; refusing to start a shared-root writer', error);
|
||||
app.exit(1);
|
||||
}
|
||||
}
|
||||
const gotTheLock = gotElectronLock && gotFileLock;
|
||||
|
||||
if (gotTheLock) {
|
||||
try {
|
||||
// No shared-root state may be created or migrated until this process owns
|
||||
// the cross-install writer lock.
|
||||
initializeClawXDataLayout(clawXDataLayout);
|
||||
} catch (error) {
|
||||
releaseProcessInstanceFileLock();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Global references
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
const gatewayManager = new GatewayManager();
|
||||
const clawHubService = new ClawHubService();
|
||||
const hostEventBus = new HostEventBus();
|
||||
let hostApiServer: Server | null = null;
|
||||
let gatewayManager!: GatewayManager;
|
||||
let runtimeManager!: RuntimeManager;
|
||||
let clawHubService!: ClawHubService;
|
||||
const hostApiRegistry = new HostApiRegistry();
|
||||
const webBrowserGuestRegistry = new WebBrowserGuestRegistry();
|
||||
let webBrowserSession!: Session;
|
||||
const mainWindowFocusState = createMainWindowFocusState();
|
||||
const quitLifecycleState = createQuitLifecycleState();
|
||||
|
||||
function sendMainWindowEvent(channel: string, payload: unknown): void {
|
||||
const win = mainWindow;
|
||||
if (!win || win.isDestroyed()) return;
|
||||
win.webContents.send(channel, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the icons directory path (works in both dev and packaged mode)
|
||||
@@ -102,7 +203,8 @@ function getAppIcon(): Electron.NativeImage | undefined {
|
||||
*/
|
||||
function createWindow(): BrowserWindow {
|
||||
const isMac = process.platform === 'darwin';
|
||||
|
||||
const isWindows = process.platform === 'win32';
|
||||
const useCustomTitleBar = isWindows;
|
||||
const win = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 800,
|
||||
@@ -116,31 +218,120 @@ function createWindow(): BrowserWindow {
|
||||
sandbox: false,
|
||||
webviewTag: true, // Enable <webview> for embedding OpenClaw Control UI
|
||||
},
|
||||
titleBarStyle: isMac ? 'hiddenInset' : 'hidden',
|
||||
trafficLightPosition: isMac ? { x: 16, y: 16 } : undefined,
|
||||
frame: isMac,
|
||||
titleBarStyle: isMac ? 'hiddenInset' : useCustomTitleBar ? 'hidden' : 'default',
|
||||
trafficLightPosition: isMac
|
||||
? getMacTrafficLightPosition(false)
|
||||
: undefined,
|
||||
frame: isMac || !useCustomTitleBar,
|
||||
show: false,
|
||||
});
|
||||
|
||||
// Show window when ready to prevent visual flash
|
||||
win.once('ready-to-show', () => {
|
||||
win.show();
|
||||
installWebBrowserGuestPolicy(win.webContents, {
|
||||
browserSession: webBrowserSession,
|
||||
registry: webBrowserGuestRegistry,
|
||||
});
|
||||
|
||||
// Handle external links
|
||||
registerZoomShortcuts(win);
|
||||
|
||||
// Handle external links — only allow safe protocols to prevent arbitrary
|
||||
// command execution via shell.openExternal() (e.g. file://, ms-msdt:, etc.)
|
||||
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||
shell.openExternal(url);
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol === 'https:' || parsed.protocol === 'http:') {
|
||||
shell.openExternal(url);
|
||||
} else {
|
||||
logger.warn(`Blocked openExternal for disallowed protocol: ${parsed.protocol}`);
|
||||
}
|
||||
} catch {
|
||||
logger.warn(`Blocked openExternal for malformed URL: ${url}`);
|
||||
}
|
||||
return { action: 'deny' };
|
||||
});
|
||||
|
||||
// Load the app
|
||||
return win;
|
||||
}
|
||||
|
||||
function loadMainWindow(win: BrowserWindow): void {
|
||||
const shouldSkipSetupForE2E = process.env.CLAWX_E2E_SKIP_SETUP === '1';
|
||||
|
||||
if (process.env.VITE_DEV_SERVER_URL) {
|
||||
win.loadURL(process.env.VITE_DEV_SERVER_URL);
|
||||
win.webContents.openDevTools();
|
||||
const rendererUrl = new URL(process.env.VITE_DEV_SERVER_URL);
|
||||
if (shouldSkipSetupForE2E) {
|
||||
rendererUrl.searchParams.set('e2eSkipSetup', '1');
|
||||
}
|
||||
win.loadURL(rendererUrl.toString());
|
||||
if (!isE2EMode) {
|
||||
win.webContents.openDevTools();
|
||||
}
|
||||
} else {
|
||||
win.loadFile(join(__dirname, '../../dist/index.html'));
|
||||
win.loadFile(join(__dirname, '../../dist/index.html'), {
|
||||
query: shouldSkipSetupForE2E
|
||||
? { e2eSkipSetup: '1' }
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function focusWindow(win: BrowserWindow): void {
|
||||
if (win.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (win.isMinimized()) {
|
||||
win.restore();
|
||||
}
|
||||
|
||||
win.show();
|
||||
win.focus();
|
||||
}
|
||||
|
||||
function focusMainWindow(): void {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearPendingSecondInstanceFocus(mainWindowFocusState);
|
||||
focusWindow(mainWindow);
|
||||
}
|
||||
|
||||
function createMainWindow(): BrowserWindow {
|
||||
const win = createWindow();
|
||||
|
||||
win.once('ready-to-show', () => {
|
||||
if (mainWindow !== win) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
void getSetting('sidebarCollapsed').then((sidebarCollapsed) => {
|
||||
syncMacTrafficLightPosition(win, sidebarCollapsed);
|
||||
});
|
||||
}
|
||||
|
||||
const action = consumeMainWindowReady(mainWindowFocusState);
|
||||
if (action === 'focus') {
|
||||
focusWindow(win);
|
||||
return;
|
||||
}
|
||||
|
||||
win.show();
|
||||
});
|
||||
|
||||
win.on('close', (event) => {
|
||||
if (!isQuitting() && !isE2EMode) {
|
||||
event.preventDefault();
|
||||
win.hide();
|
||||
}
|
||||
});
|
||||
|
||||
win.on('closed', () => {
|
||||
if (mainWindow === win) {
|
||||
mainWindow = null;
|
||||
}
|
||||
});
|
||||
|
||||
mainWindow = win;
|
||||
return win;
|
||||
}
|
||||
|
||||
@@ -152,27 +343,44 @@ async function initialize(): Promise<void> {
|
||||
logger.init();
|
||||
logger.info('=== ClawX Application Starting ===');
|
||||
logger.debug(
|
||||
`Runtime: platform=${process.platform}/${process.arch}, electron=${process.versions.electron}, node=${process.versions.node}, packaged=${app.isPackaged}`
|
||||
`Runtime: platform=${process.platform}/${process.arch}, electron=${process.versions.electron}, node=${process.versions.node}, packaged=${app.isPackaged}, pid=${process.pid}, ppid=${process.ppid}`
|
||||
);
|
||||
const legacyMigration = await migrateLegacyClawXData({
|
||||
legacyElectronUserDataDir,
|
||||
layout: clawXDataLayout,
|
||||
});
|
||||
if (legacyMigration.copied.length > 0) {
|
||||
logger.info(`Imported ${legacyMigration.copied.length} legacy ClawX data path(s) into ${clawXDataLayout.root}`);
|
||||
}
|
||||
const migratedSecretCount = await migrateLegacyProviderSecretsToVault();
|
||||
if (migratedSecretCount > 0) {
|
||||
logger.info(`Migrated ${migratedSecretCount} provider credential account(s) into the encrypted ClawX vault`);
|
||||
}
|
||||
|
||||
// Warm up network optimization (non-blocking)
|
||||
void warmupNetworkOptimization();
|
||||
webBrowserSession = configureWebBrowserSession({
|
||||
registry: webBrowserGuestRegistry,
|
||||
getMainWindow: () => mainWindow,
|
||||
});
|
||||
|
||||
// Initialize Telemetry early
|
||||
await initTelemetry();
|
||||
if (!isE2EMode) {
|
||||
// Warm up network optimization (non-blocking)
|
||||
void warmupNetworkOptimization();
|
||||
|
||||
// Apply persisted proxy settings before creating windows or network requests.
|
||||
await applyProxySettings();
|
||||
await syncLaunchAtStartupSettingFromStore();
|
||||
// Initialize Telemetry early
|
||||
await initTelemetry();
|
||||
|
||||
// Apply persisted proxy settings before creating windows or network requests.
|
||||
await applyProxySettings();
|
||||
await syncLaunchAtStartupSettingFromStore();
|
||||
} else {
|
||||
logger.info('Running in E2E mode: startup side effects minimized');
|
||||
}
|
||||
|
||||
// Set application menu
|
||||
createMenu();
|
||||
await createMenu();
|
||||
|
||||
// Create the main window
|
||||
mainWindow = createWindow();
|
||||
|
||||
// Create system tray
|
||||
createTray(mainWindow);
|
||||
const window = createMainWindow();
|
||||
|
||||
// Override security headers ONLY for the OpenClaw Gateway Control UI.
|
||||
// The URL filter ensures this callback only fires for gateway requests,
|
||||
@@ -198,140 +406,197 @@ async function initialize(): Promise<void> {
|
||||
);
|
||||
|
||||
// Register IPC handlers
|
||||
registerIpcHandlers(gatewayManager, clawHubService, mainWindow);
|
||||
|
||||
hostApiServer = startHostApiServer({
|
||||
registerIpcHandlers(
|
||||
gatewayManager,
|
||||
runtimeManager,
|
||||
clawHubService,
|
||||
eventBus: hostEventBus,
|
||||
mainWindow,
|
||||
window,
|
||||
hostApiRegistry,
|
||||
webBrowserSession,
|
||||
webBrowserGuestRegistry,
|
||||
);
|
||||
|
||||
await runtimeManager.getActiveKind();
|
||||
loadMainWindow(window);
|
||||
|
||||
// Create system tray
|
||||
if (!isE2EMode) {
|
||||
createTray(window);
|
||||
}
|
||||
|
||||
// Initialize extension system
|
||||
await extensionRegistry.initialize({
|
||||
gatewayManager,
|
||||
runtimeManager,
|
||||
getMainWindow: () => mainWindow,
|
||||
hostApi: {
|
||||
register: (extensionId, contributions) => (
|
||||
hostApiRegistry.registerExtensionContributions(extensionId, contributions)
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
// Wire marketplace provider to ClawHubService if an extension provides one
|
||||
const marketplaceProvider = extensionRegistry.getMarketplaceProvider();
|
||||
if (marketplaceProvider) {
|
||||
clawHubService.setMarketplaceProvider(marketplaceProvider);
|
||||
}
|
||||
|
||||
// Register update handlers
|
||||
registerUpdateHandlers(appUpdater, mainWindow);
|
||||
registerUpdateHandlers(appUpdater, window);
|
||||
|
||||
// Note: Auto-check for updates is driven by the renderer (update store init)
|
||||
// so it respects the user's "Auto-check for updates" setting.
|
||||
|
||||
// Minimize to tray on close instead of quitting (macOS & Windows)
|
||||
mainWindow.on('close', (event) => {
|
||||
if (!isQuitting()) {
|
||||
event.preventDefault();
|
||||
mainWindow?.hide();
|
||||
}
|
||||
});
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
// Seed a stable default IDENTITY.md before the Gateway initializes the
|
||||
// workspace so ClawX desktop sessions skip OpenClaw's chat-first bootstrap.
|
||||
if (!isE2EMode) {
|
||||
void ensureClawXDefaultIdentity().catch((error) => {
|
||||
logger.warn('Failed to seed default ClawX identity:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// Repair any bootstrap files that only contain ClawX markers (no OpenClaw
|
||||
// template content). This fixes a race condition where ensureClawXContext()
|
||||
// previously created the file before the gateway could seed the full template.
|
||||
void repairClawXOnlyBootstrapFiles().catch((error) => {
|
||||
logger.warn('Failed to repair bootstrap files:', error);
|
||||
});
|
||||
if (!isE2EMode) {
|
||||
void repairClawXOnlyBootstrapFiles().catch((error) => {
|
||||
logger.warn('Failed to repair bootstrap files:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// Pre-deploy built-in skills (feishu-doc, feishu-drive, feishu-perm, feishu-wiki)
|
||||
// to ~/.openclaw/skills/ so they are immediately available without manual install.
|
||||
void ensureBuiltinSkillsInstalled().catch((error) => {
|
||||
logger.warn('Failed to install built-in skills:', error);
|
||||
});
|
||||
if (!isE2EMode) {
|
||||
void ensureBuiltinSkillsInstalled().catch((error) => {
|
||||
logger.warn('Failed to install built-in skills:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// Keep community builds aligned with Clawx-biz by physically trimming
|
||||
// bundled OpenClaw consumer skills on startup (dev + packaged), keeping only
|
||||
// `skill-creator`. This also prunes stale openclaw.json entries for trimmed
|
||||
// bundled skills so we do not keep `enabled: false` config for skills that no
|
||||
// longer exist.
|
||||
if (!isE2EMode) {
|
||||
void trimBundledOpenClawSkillsAndConfigs().then(({ removed, removedConfigs, kept }) => {
|
||||
if (removed > 0 || removedConfigs > 0) {
|
||||
logger.info(
|
||||
`Trimmed bundled OpenClaw skills: removed ${removed}, pruned configs ${removedConfigs}, kept ${kept.join(', ')}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Pre-deploy bundled third-party skills from resources/preinstalled-skills.
|
||||
// This installs full skill directories (not only SKILL.md) in an idempotent,
|
||||
// non-destructive way and never blocks startup.
|
||||
void ensurePreinstalledSkillsInstalled().catch((error) => {
|
||||
logger.warn('Failed to install preinstalled skills:', error);
|
||||
});
|
||||
if (!isE2EMode) {
|
||||
void ensurePreinstalledSkillsInstalled().catch((error) => {
|
||||
logger.warn('Failed to install preinstalled skills:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// Plugin installation is now configuration-driven:
|
||||
// - When a channel is added via UI: ensureXxxPluginInstalled() in IPC handlers
|
||||
// - When Gateway starts: ensureConfiguredPluginsUpgraded() in config-sync.ts
|
||||
// No need to pre-install all bundled plugins at app startup.
|
||||
|
||||
// Bridge gateway and host-side events before any auto-start logic runs, so
|
||||
// renderer subscribers observe the full startup lifecycle.
|
||||
gatewayManager.on('status', (status: { state: string }) => {
|
||||
hostEventBus.emit('gateway:status', status);
|
||||
if (status.state === 'running') {
|
||||
runtimeManager.on('status', (status: { state: string; runtimeKind?: string }) => {
|
||||
sendMainWindowEvent('gateway:status-changed', status);
|
||||
if (status.runtimeKind === 'openclaw' && status.state === 'running' && !isE2EMode) {
|
||||
void ensureClawXContext().catch((error) => {
|
||||
logger.warn('Failed to re-merge ClawX context after gateway reconnect:', error);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
gatewayManager.on('error', (error) => {
|
||||
hostEventBus.emit('gateway:error', { message: error.message });
|
||||
runtimeManager.on('error', (error) => {
|
||||
sendMainWindowEvent('gateway:error', { message: error.message });
|
||||
});
|
||||
|
||||
gatewayManager.on('notification', (notification) => {
|
||||
hostEventBus.emit('gateway:notification', notification);
|
||||
runtimeManager.on('notification', (notification) => {
|
||||
sendMainWindowEvent('gateway:notification', notification);
|
||||
});
|
||||
|
||||
gatewayManager.on('chat:message', (data) => {
|
||||
hostEventBus.emit('gateway:chat-message', data);
|
||||
runtimeManager.on('gateway:health', (data) => {
|
||||
sendMainWindowEvent('gateway:health-changed', data);
|
||||
});
|
||||
|
||||
gatewayManager.on('channel:status', (data) => {
|
||||
hostEventBus.emit('gateway:channel-status', data);
|
||||
runtimeManager.on('gateway:presence', (data) => {
|
||||
sendMainWindowEvent('gateway:presence-changed', data);
|
||||
});
|
||||
|
||||
gatewayManager.on('exit', (code) => {
|
||||
hostEventBus.emit('gateway:exit', { code });
|
||||
runtimeManager.on('chat:message', (data) => {
|
||||
sendMainWindowEvent('gateway:chat-message', data);
|
||||
});
|
||||
|
||||
runtimeManager.on('chat:runtime-event', (data) => {
|
||||
sendMainWindowEvent('chat:runtime-event', data);
|
||||
});
|
||||
|
||||
runtimeManager.on('channel:status', (data) => {
|
||||
sendMainWindowEvent('gateway:channel-status', data);
|
||||
});
|
||||
|
||||
runtimeManager.on('exit', (code) => {
|
||||
sendMainWindowEvent('gateway:exit', { code });
|
||||
});
|
||||
|
||||
deviceOAuthManager.on('oauth:code', (payload) => {
|
||||
hostEventBus.emit('oauth:code', payload);
|
||||
});
|
||||
|
||||
deviceOAuthManager.on('oauth:start', (payload) => {
|
||||
hostEventBus.emit('oauth:start', payload);
|
||||
sendMainWindowEvent('oauth:code', payload);
|
||||
});
|
||||
|
||||
deviceOAuthManager.on('oauth:success', (payload) => {
|
||||
hostEventBus.emit('oauth:success', { ...payload, success: true });
|
||||
sendMainWindowEvent('oauth:success', { ...payload, success: true });
|
||||
});
|
||||
|
||||
deviceOAuthManager.on('oauth:error', (error) => {
|
||||
hostEventBus.emit('oauth:error', error);
|
||||
});
|
||||
|
||||
browserOAuthManager.on('oauth:start', (payload) => {
|
||||
hostEventBus.emit('oauth:start', payload);
|
||||
sendMainWindowEvent('oauth:error', error);
|
||||
});
|
||||
|
||||
browserOAuthManager.on('oauth:code', (payload) => {
|
||||
hostEventBus.emit('oauth:code', payload);
|
||||
sendMainWindowEvent('oauth:code', payload);
|
||||
});
|
||||
|
||||
browserOAuthManager.on('oauth:success', (payload) => {
|
||||
hostEventBus.emit('oauth:success', { ...payload, success: true });
|
||||
sendMainWindowEvent('oauth:success', { ...payload, success: true });
|
||||
});
|
||||
|
||||
browserOAuthManager.on('oauth:error', (error) => {
|
||||
hostEventBus.emit('oauth:error', error);
|
||||
sendMainWindowEvent('oauth:error', error);
|
||||
});
|
||||
|
||||
whatsAppLoginManager.on('qr', (data) => {
|
||||
hostEventBus.emit('channel:whatsapp-qr', data);
|
||||
sendMainWindowEvent('channel:whatsapp-qr', data);
|
||||
});
|
||||
|
||||
whatsAppLoginManager.on('success', (data) => {
|
||||
hostEventBus.emit('channel:whatsapp-success', data);
|
||||
sendMainWindowEvent('channel:whatsapp-success', data);
|
||||
});
|
||||
|
||||
whatsAppLoginManager.on('error', (error) => {
|
||||
hostEventBus.emit('channel:whatsapp-error', error);
|
||||
sendMainWindowEvent('channel:whatsapp-error', error);
|
||||
});
|
||||
|
||||
// Start Gateway automatically (this seeds missing bootstrap files with full templates)
|
||||
const gatewayAutoStart = await getSetting('gatewayAutoStart');
|
||||
if (gatewayAutoStart) {
|
||||
if (!isE2EMode && gatewayAutoStart) {
|
||||
try {
|
||||
await syncAllProviderAuthToRuntime();
|
||||
logger.debug('Auto-starting Gateway...');
|
||||
await gatewayManager.start();
|
||||
logger.info('Gateway auto-start succeeded');
|
||||
if (await runtimeManager.getActiveKind() === 'openclaw') {
|
||||
await syncAllProviderAuthToRuntime();
|
||||
}
|
||||
logger.debug(`Auto-starting ${await runtimeManager.getActiveKind()} runtime...`);
|
||||
await runtimeManager.start();
|
||||
logger.info('Runtime auto-start succeeded');
|
||||
} catch (error) {
|
||||
logger.error('Gateway auto-start failed:', error);
|
||||
logger.error('Runtime auto-start failed:', error);
|
||||
mainWindow?.webContents.send('gateway:error', String(error));
|
||||
}
|
||||
} else if (isE2EMode) {
|
||||
logger.info('Gateway auto-start skipped in E2E mode');
|
||||
} else {
|
||||
logger.info('Gateway auto-start disabled in settings');
|
||||
}
|
||||
@@ -339,65 +604,170 @@ async function initialize(): Promise<void> {
|
||||
// Merge ClawX context snippets into the workspace bootstrap files.
|
||||
// The gateway seeds workspace files asynchronously after its HTTP server
|
||||
// is ready, so ensureClawXContext will retry until the target files appear.
|
||||
void ensureClawXContext().catch((error) => {
|
||||
logger.warn('Failed to merge ClawX context into workspace:', error);
|
||||
});
|
||||
if (!isE2EMode) {
|
||||
void ensureClawXContext().catch((error) => {
|
||||
logger.warn('Failed to merge ClawX context into workspace:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-install openclaw CLI and shell completions (non-blocking).
|
||||
void autoInstallCliIfNeeded((installedPath) => {
|
||||
mainWindow?.webContents.send('openclaw:cli-installed', installedPath);
|
||||
}).then(() => {
|
||||
generateCompletionCache();
|
||||
installCompletionToProfile();
|
||||
}).catch((error) => {
|
||||
logger.warn('CLI auto-install failed:', error);
|
||||
if (!isE2EMode) {
|
||||
void autoInstallCliIfNeeded((installedPath) => {
|
||||
mainWindow?.webContents.send('openclaw:cli-installed', installedPath);
|
||||
}).then(() => {
|
||||
generateCompletionCache();
|
||||
installCompletionToProfile();
|
||||
}).catch((error) => {
|
||||
logger.warn('CLI auto-install failed:', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (gotTheLock) {
|
||||
const requestQuitOnSignal = createSignalQuitHandler({
|
||||
logInfo: (message) => logger.info(message),
|
||||
requestQuit: () => app.quit(),
|
||||
});
|
||||
|
||||
process.on('exit', () => {
|
||||
releaseProcessInstanceFileLock();
|
||||
});
|
||||
|
||||
process.once('SIGINT', () => requestQuitOnSignal('SIGINT'));
|
||||
process.once('SIGTERM', () => requestQuitOnSignal('SIGTERM'));
|
||||
|
||||
app.on('will-quit', () => {
|
||||
releaseProcessInstanceFileLock();
|
||||
});
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
app.setAppUserModelId(WINDOWS_APP_USER_MODEL_ID);
|
||||
}
|
||||
|
||||
gatewayManager = new GatewayManager();
|
||||
runtimeManager = new RuntimeManager({
|
||||
openclaw: new OpenClawRuntimeProvider(gatewayManager),
|
||||
ccConnect: new CcConnectRuntimeProvider(),
|
||||
});
|
||||
clawHubService = new ClawHubService();
|
||||
|
||||
// Register builtin extensions and load manifest
|
||||
registerAllBuiltinExtensions();
|
||||
loadExternalMainExtensions();
|
||||
void loadExtensionsFromManifest().catch((err) => {
|
||||
logger.warn('Failed to load extensions from manifest:', err);
|
||||
});
|
||||
|
||||
// When a second instance is launched, focus the existing window instead.
|
||||
app.on('second-instance', () => {
|
||||
logger.info('Second ClawX instance detected; redirecting to the existing window');
|
||||
|
||||
const focusRequest = requestSecondInstanceFocus(
|
||||
mainWindowFocusState,
|
||||
Boolean(mainWindow && !mainWindow.isDestroyed()),
|
||||
);
|
||||
|
||||
if (focusRequest === 'focus-now') {
|
||||
focusMainWindow();
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug('Main window is not ready yet; deferring second-instance focus until ready-to-show');
|
||||
});
|
||||
|
||||
// Application lifecycle
|
||||
app.whenReady().then(async () => {
|
||||
try {
|
||||
await initialize();
|
||||
} catch (error) {
|
||||
logger.error('Application initialization failed:', error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Register only after initialization so activation cannot race the initial
|
||||
// window or claim the single browser guest before host handlers are ready.
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
loadMainWindow(createMainWindow());
|
||||
} else {
|
||||
focusMainWindow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin' || isE2EMode) {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('before-quit', (event) => {
|
||||
setQuitting();
|
||||
const action = requestQuitLifecycleAction(quitLifecycleState);
|
||||
|
||||
if (action === 'allow-quit') {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
if (action === 'cleanup-in-progress') {
|
||||
logger.debug('Quit requested while cleanup already in progress; waiting for shutdown task to finish');
|
||||
return;
|
||||
}
|
||||
|
||||
void extensionRegistry.teardownAll();
|
||||
|
||||
const stopPromise = runtimeManager.stop().catch((err) => {
|
||||
logger.warn('runtimeManager.stop() error during quit:', err);
|
||||
});
|
||||
const timeoutPromise = new Promise<'timeout'>((resolve) => {
|
||||
setTimeout(() => resolve('timeout'), 5000);
|
||||
});
|
||||
|
||||
void Promise.race([stopPromise.then(() => 'stopped' as const), timeoutPromise]).then((result) => {
|
||||
if (result === 'timeout') {
|
||||
logger.warn('Runtime shutdown timed out during app quit; proceeding with forced quit');
|
||||
if (runtimeManager.getActiveProvider().kind === 'openclaw') {
|
||||
void gatewayManager.forceTerminateOwnedProcessForQuit().then((terminated) => {
|
||||
if (terminated) {
|
||||
logger.warn('Forced gateway process termination completed after quit timeout');
|
||||
}
|
||||
}).catch((err) => {
|
||||
logger.warn('Forced gateway termination failed after quit timeout:', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
markQuitCleanupCompleted(quitLifecycleState);
|
||||
app.quit();
|
||||
});
|
||||
});
|
||||
|
||||
// Best-effort Gateway cleanup on unexpected crashes.
|
||||
// These handlers attempt to terminate the Gateway child process within a
|
||||
// short timeout before force-exiting, preventing orphaned processes.
|
||||
const emergencyGatewayCleanup = (reason: string, error: unknown): void => {
|
||||
logger.error(`${reason}:`, error);
|
||||
try {
|
||||
void gatewayManager?.stop().catch(() => { /* ignore */ });
|
||||
void runtimeManager?.stop().catch(() => { /* ignore */ });
|
||||
} catch {
|
||||
// ignore — stop() may not be callable if state is corrupted
|
||||
}
|
||||
// Give Gateway stop a brief window, then force-exit.
|
||||
setTimeout(() => {
|
||||
process.exit(1);
|
||||
}, 3000).unref();
|
||||
};
|
||||
|
||||
process.on('uncaughtException', (error) => {
|
||||
emergencyGatewayCleanup('Uncaught exception in main process', error);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
emergencyGatewayCleanup('Unhandled promise rejection in main process', reason);
|
||||
});
|
||||
}
|
||||
|
||||
// When a second instance is launched, focus the existing window instead.
|
||||
app.on('second-instance', () => {
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
});
|
||||
|
||||
// Application lifecycle
|
||||
app.whenReady().then(() => {
|
||||
void initialize().catch((error) => {
|
||||
logger.error('Application initialization failed:', error);
|
||||
});
|
||||
|
||||
// Register activate handler AFTER app is ready to prevent
|
||||
// "Cannot create BrowserWindow before app is ready" on macOS.
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
mainWindow = createWindow();
|
||||
} else if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
// On macOS, clicking the dock icon should show the window if it's hidden
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('before-quit', () => {
|
||||
setQuitting();
|
||||
hostEventBus.closeAll();
|
||||
hostApiServer?.close();
|
||||
// Fire-and-forget: do not await gatewayManager.stop() here.
|
||||
// Awaiting inside before-quit can stall Electron's quit sequence.
|
||||
void gatewayManager.stop().catch((err) => {
|
||||
logger.warn('gatewayManager.stop() error during quit:', err);
|
||||
});
|
||||
});
|
||||
|
||||
// Export for testing
|
||||
export { mainWindow, gatewayManager };
|
||||
export { mainWindow, gatewayManager, runtimeManager };
|
||||
|
||||
+615
-1547
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
||||
import type { HostApiContract } from '@shared/host-api/contract';
|
||||
|
||||
export type HostRequest = {
|
||||
id: string;
|
||||
module: string;
|
||||
action: string;
|
||||
payload?: unknown;
|
||||
};
|
||||
|
||||
export type HostErrorCode = 'VALIDATION' | 'UNSUPPORTED' | 'INTERNAL';
|
||||
|
||||
export type HostResponse<T = unknown> =
|
||||
| { id?: string; ok: true; data: T }
|
||||
| { id?: string; ok: false; error: { code: HostErrorCode; message: string; details?: unknown } };
|
||||
|
||||
export type RuntimeHostAction = (payload?: unknown) => Promise<unknown> | unknown;
|
||||
type MaybePromise<T> = T | Promise<T>;
|
||||
|
||||
type HostServiceFunction<TFunction> = TFunction extends (...args: infer Args) => infer Result
|
||||
? (...args: Args) => MaybePromise<Awaited<Result>>
|
||||
: never;
|
||||
|
||||
type HostServiceModule<TModule> = {
|
||||
[A in keyof TModule]: HostServiceFunction<TModule[A]>;
|
||||
};
|
||||
|
||||
export type HostServiceRegistry = {
|
||||
[M in keyof HostApiContract]?: Partial<HostServiceModule<HostApiContract[M]>>;
|
||||
};
|
||||
export type CompleteHostServiceRegistry = {
|
||||
[M in keyof HostApiContract]: HostServiceModule<HostApiContract[M]>;
|
||||
};
|
||||
|
||||
export type HostApiContribution = {
|
||||
module: string;
|
||||
actions: Record<string, RuntimeHostAction>;
|
||||
};
|
||||
|
||||
export type HostApiContributionRegistrar = {
|
||||
register: (extensionId: string, contributions: HostApiContribution[]) => () => void;
|
||||
};
|
||||
|
||||
export function isHostRequest(value: unknown): value is HostRequest {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const record = value as Record<string, unknown>;
|
||||
return typeof record.id === 'string'
|
||||
&& record.id.length > 0
|
||||
&& typeof record.module === 'string'
|
||||
&& record.module.length > 0
|
||||
&& typeof record.action === 'string'
|
||||
&& record.action.length > 0;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import {
|
||||
type HostApiContribution,
|
||||
type HostResponse,
|
||||
type HostServiceRegistry,
|
||||
type RuntimeHostAction,
|
||||
isHostRequest,
|
||||
} from './host-contract';
|
||||
|
||||
type RegisteredHostAction = {
|
||||
action: RuntimeHostAction;
|
||||
ownerId: string;
|
||||
};
|
||||
|
||||
function assertValidContributionKey(kind: 'module' | 'action', value: string): void {
|
||||
if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(value)) {
|
||||
throw new Error(`Invalid host API ${kind}: ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class HostApiRegistry {
|
||||
private modules = new Map<string, Map<string, RegisteredHostAction>>();
|
||||
|
||||
registerCoreServices(services: HostServiceRegistry): void {
|
||||
for (const [moduleName, actions] of Object.entries(services)) {
|
||||
if (!actions || typeof actions !== 'object') continue;
|
||||
for (const [actionName, action] of Object.entries(actions)) {
|
||||
if (typeof action !== 'function') continue;
|
||||
this.registerAction(moduleName, actionName, action as RuntimeHostAction, 'core');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registerExtensionContributions(extensionId: string, contributions: HostApiContribution[]): () => void {
|
||||
const registered: Array<{ module: string; action: string }> = [];
|
||||
|
||||
for (const contribution of contributions) {
|
||||
assertValidContributionKey('module', contribution.module);
|
||||
for (const [actionName, action] of Object.entries(contribution.actions)) {
|
||||
assertValidContributionKey('action', actionName);
|
||||
this.registerAction(contribution.module, actionName, action, extensionId);
|
||||
registered.push({ module: contribution.module, action: actionName });
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const { module, action } of registered) {
|
||||
const moduleActions = this.modules.get(module);
|
||||
const registeredAction = moduleActions?.get(action);
|
||||
if (registeredAction?.ownerId === extensionId) {
|
||||
moduleActions?.delete(action);
|
||||
}
|
||||
if (moduleActions?.size === 0) {
|
||||
this.modules.delete(module);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
resolve(moduleName: string, actionName: string): RuntimeHostAction | undefined {
|
||||
return this.modules.get(moduleName)?.get(actionName)?.action;
|
||||
}
|
||||
|
||||
private registerAction(
|
||||
moduleName: string,
|
||||
actionName: string,
|
||||
action: RuntimeHostAction,
|
||||
ownerId: string,
|
||||
): void {
|
||||
const moduleActions = this.modules.get(moduleName) ?? new Map<string, RegisteredHostAction>();
|
||||
if (moduleActions.has(actionName)) {
|
||||
throw new Error(`Host API action already registered: ${moduleName}.${actionName}`);
|
||||
}
|
||||
moduleActions.set(actionName, { action, ownerId });
|
||||
this.modules.set(moduleName, moduleActions);
|
||||
}
|
||||
}
|
||||
|
||||
function toHostApiRegistry(registryOrServices: HostApiRegistry | HostServiceRegistry): HostApiRegistry {
|
||||
if (registryOrServices instanceof HostApiRegistry) {
|
||||
return registryOrServices;
|
||||
}
|
||||
const registry = new HostApiRegistry();
|
||||
registry.registerCoreServices(registryOrServices);
|
||||
return registry;
|
||||
}
|
||||
|
||||
export function createHostInvokeDispatcher(registryOrServices: HostApiRegistry | HostServiceRegistry) {
|
||||
const registry = toHostApiRegistry(registryOrServices);
|
||||
return async function dispatchHostRequest(request: unknown): Promise<HostResponse> {
|
||||
const requestId = request && typeof request === 'object'
|
||||
? String((request as Record<string, unknown>).id ?? '')
|
||||
: undefined;
|
||||
|
||||
if (!isHostRequest(request)) {
|
||||
return {
|
||||
id: requestId,
|
||||
ok: false,
|
||||
error: { code: 'VALIDATION', message: 'Invalid host request format' },
|
||||
};
|
||||
}
|
||||
|
||||
const action = registry.resolve(request.module, request.action);
|
||||
if (typeof action !== 'function') {
|
||||
return {
|
||||
id: request.id,
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'UNSUPPORTED',
|
||||
message: `Unsupported host request: ${request.module}.${request.action}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await action(request.payload);
|
||||
return { id: request.id, ok: true, data };
|
||||
} catch (error) {
|
||||
return {
|
||||
id: request.id,
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'INTERNAL',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function registerHostInvokeHandler(registry: HostApiRegistry): void {
|
||||
const dispatch = createHostInvokeDispatcher(registry);
|
||||
ipcMain.handle('host:invoke', async (_event, request: unknown) => dispatch(request));
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { AppSettings } from '../../utils/store';
|
||||
|
||||
export type AppRequest = {
|
||||
id?: string;
|
||||
module: string;
|
||||
action: string;
|
||||
payload?: unknown;
|
||||
};
|
||||
|
||||
export type AppErrorCode = 'VALIDATION' | 'PERMISSION' | 'TIMEOUT' | 'GATEWAY' | 'INTERNAL' | 'UNSUPPORTED';
|
||||
|
||||
export type AppResponse = {
|
||||
id?: string;
|
||||
ok: boolean;
|
||||
data?: unknown;
|
||||
error?: {
|
||||
code: AppErrorCode;
|
||||
message: string;
|
||||
details?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
export function mapAppErrorCode(error: unknown): AppErrorCode {
|
||||
const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
|
||||
if (msg.includes('timeout')) return 'TIMEOUT';
|
||||
if (msg.includes('permission') || msg.includes('denied') || msg.includes('forbidden')) return 'PERMISSION';
|
||||
if (msg.includes('gateway')) return 'GATEWAY';
|
||||
if (msg.includes('invalid') || msg.includes('required')) return 'VALIDATION';
|
||||
return 'INTERNAL';
|
||||
}
|
||||
|
||||
export function isProxyKey(key: keyof AppSettings): boolean {
|
||||
return (
|
||||
key === 'proxyEnabled' ||
|
||||
key === 'proxyServer' ||
|
||||
key === 'proxyHttpServer' ||
|
||||
key === 'proxyHttpsServer' ||
|
||||
key === 'proxyAllServer' ||
|
||||
key === 'proxyBypassRules'
|
||||
);
|
||||
}
|
||||
|
||||
export function isLaunchAtStartupKey(key: keyof AppSettings): boolean {
|
||||
return key === 'launchAtStartup';
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export interface MainWindowFocusState {
|
||||
pendingSecondInstanceFocus: boolean;
|
||||
}
|
||||
|
||||
export type SecondInstanceFocusRequest = 'focus-now' | 'defer';
|
||||
export type MainWindowReadyAction = 'show' | 'focus';
|
||||
|
||||
export function createMainWindowFocusState(): MainWindowFocusState {
|
||||
return {
|
||||
pendingSecondInstanceFocus: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function requestSecondInstanceFocus(
|
||||
state: MainWindowFocusState,
|
||||
hasFocusableMainWindow: boolean,
|
||||
): SecondInstanceFocusRequest {
|
||||
if (hasFocusableMainWindow) {
|
||||
state.pendingSecondInstanceFocus = false;
|
||||
return 'focus-now';
|
||||
}
|
||||
|
||||
state.pendingSecondInstanceFocus = true;
|
||||
return 'defer';
|
||||
}
|
||||
|
||||
export function consumeMainWindowReady(state: MainWindowFocusState): MainWindowReadyAction {
|
||||
if (state.pendingSecondInstanceFocus) {
|
||||
state.pendingSecondInstanceFocus = false;
|
||||
return 'focus';
|
||||
}
|
||||
|
||||
return 'show';
|
||||
}
|
||||
|
||||
export function clearPendingSecondInstanceFocus(state: MainWindowFocusState): void {
|
||||
state.pendingSecondInstanceFocus = false;
|
||||
}
|
||||
+81
-56
@@ -3,12 +3,33 @@
|
||||
* Creates the native application menu for macOS/Windows/Linux
|
||||
*/
|
||||
import { Menu, app, shell, BrowserWindow } from 'electron';
|
||||
import { MENU_LABELS } from '@shared/i18n/resources';
|
||||
import { resolveSupportedLanguage, type LanguageCode } from '@shared/language';
|
||||
import { getSetting } from '../utils/store';
|
||||
|
||||
function applyAppName(label: string): string {
|
||||
return label.replaceAll('{{appName}}', app.name);
|
||||
}
|
||||
|
||||
async function resolveMenuLanguage(language?: string): Promise<LanguageCode> {
|
||||
if (language) return resolveSupportedLanguage(language);
|
||||
try {
|
||||
return resolveSupportedLanguage(await getSetting('language'));
|
||||
} catch {
|
||||
return resolveSupportedLanguage(app.getLocale());
|
||||
}
|
||||
}
|
||||
|
||||
function getMenuTargetWindow(): BrowserWindow | null {
|
||||
return BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows().find((win) => !win.isDestroyed()) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create application menu
|
||||
*/
|
||||
export function createMenu(): void {
|
||||
export async function createMenu(language?: string): Promise<void> {
|
||||
const isMac = process.platform === 'darwin';
|
||||
const labels = MENU_LABELS[await resolveMenuLanguage(language)];
|
||||
|
||||
const template: Electron.MenuItemConstructorOptions[] = [
|
||||
// App menu (macOS only)
|
||||
@@ -17,24 +38,24 @@ export function createMenu(): void {
|
||||
{
|
||||
label: app.name,
|
||||
submenu: [
|
||||
{ role: 'about' as const },
|
||||
{ role: 'about' as const, label: applyAppName(labels.app.about) },
|
||||
{ type: 'separator' as const },
|
||||
{
|
||||
label: 'Preferences...',
|
||||
label: labels.app.preferences,
|
||||
accelerator: 'Cmd+,',
|
||||
click: () => {
|
||||
const win = BrowserWindow.getFocusedWindow();
|
||||
const win = getMenuTargetWindow();
|
||||
win?.webContents.send('navigate', '/settings');
|
||||
},
|
||||
},
|
||||
{ type: 'separator' as const },
|
||||
{ role: 'services' as const },
|
||||
{ role: 'services' as const, label: labels.app.services },
|
||||
{ type: 'separator' as const },
|
||||
{ role: 'hide' as const },
|
||||
{ role: 'hideOthers' as const },
|
||||
{ role: 'unhide' as const },
|
||||
{ role: 'hide' as const, label: applyAppName(labels.app.hide) },
|
||||
{ role: 'hideOthers' as const, label: labels.app.hideOthers },
|
||||
{ role: 'unhide' as const, label: labels.app.unhide },
|
||||
{ type: 'separator' as const },
|
||||
{ role: 'quit' as const },
|
||||
{ role: 'quit' as const, label: applyAppName(labels.app.quit) },
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -42,110 +63,113 @@ export function createMenu(): void {
|
||||
|
||||
// File menu
|
||||
{
|
||||
label: 'File',
|
||||
label: labels.file.label,
|
||||
submenu: [
|
||||
{
|
||||
label: 'New Chat',
|
||||
id: 'new-chat',
|
||||
label: labels.file.newChat,
|
||||
accelerator: 'CmdOrCtrl+N',
|
||||
click: () => {
|
||||
const win = BrowserWindow.getFocusedWindow();
|
||||
win?.webContents.send('navigate', '/chat');
|
||||
const win = getMenuTargetWindow();
|
||||
win?.webContents.send('new-chat');
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
isMac ? { role: 'close' } : { role: 'quit' },
|
||||
isMac
|
||||
? { role: 'close', label: labels.file.close }
|
||||
: { role: 'quit', label: applyAppName(labels.app.quit) },
|
||||
],
|
||||
},
|
||||
|
||||
// Edit menu
|
||||
{
|
||||
label: 'Edit',
|
||||
label: labels.edit.label,
|
||||
submenu: [
|
||||
{ role: 'undo' },
|
||||
{ role: 'redo' },
|
||||
{ role: 'undo', label: labels.edit.undo },
|
||||
{ role: 'redo', label: labels.edit.redo },
|
||||
{ type: 'separator' },
|
||||
{ role: 'cut' },
|
||||
{ role: 'copy' },
|
||||
{ role: 'paste' },
|
||||
{ role: 'cut', label: labels.edit.cut },
|
||||
{ role: 'copy', label: labels.edit.copy },
|
||||
{ role: 'paste', label: labels.edit.paste },
|
||||
...(isMac
|
||||
? [
|
||||
{ role: 'pasteAndMatchStyle' as const },
|
||||
{ role: 'delete' as const },
|
||||
{ role: 'selectAll' as const },
|
||||
{ role: 'pasteAndMatchStyle' as const, label: labels.edit.pasteAndMatchStyle },
|
||||
{ role: 'delete' as const, label: labels.edit.delete },
|
||||
{ role: 'selectAll' as const, label: labels.edit.selectAll },
|
||||
]
|
||||
: [
|
||||
{ role: 'delete' as const },
|
||||
{ role: 'delete' as const, label: labels.edit.delete },
|
||||
{ type: 'separator' as const },
|
||||
{ role: 'selectAll' as const },
|
||||
{ role: 'selectAll' as const, label: labels.edit.selectAll },
|
||||
]),
|
||||
],
|
||||
},
|
||||
|
||||
// View menu
|
||||
{
|
||||
label: 'View',
|
||||
label: labels.view.label,
|
||||
submenu: [
|
||||
{ role: 'reload' },
|
||||
{ role: 'forceReload' },
|
||||
{ role: 'toggleDevTools' },
|
||||
{ role: 'reload', label: labels.view.reload },
|
||||
{ role: 'forceReload', label: labels.view.forceReload },
|
||||
{ role: 'toggleDevTools', label: labels.view.toggleDevTools },
|
||||
{ type: 'separator' },
|
||||
{ role: 'resetZoom' },
|
||||
{ role: 'zoomIn' },
|
||||
{ role: 'zoomOut' },
|
||||
{ role: 'resetZoom', label: labels.view.resetZoom },
|
||||
{ role: 'zoomIn', label: labels.view.zoomIn },
|
||||
{ role: 'zoomOut', label: labels.view.zoomOut },
|
||||
{ type: 'separator' },
|
||||
{ role: 'togglefullscreen' },
|
||||
{ role: 'togglefullscreen', label: labels.view.toggleFullscreen },
|
||||
],
|
||||
},
|
||||
|
||||
// Navigate menu
|
||||
{
|
||||
label: 'Navigate',
|
||||
label: labels.navigate.label,
|
||||
submenu: [
|
||||
{
|
||||
label: 'Dashboard',
|
||||
label: labels.navigate.dashboard,
|
||||
accelerator: 'CmdOrCtrl+1',
|
||||
click: () => {
|
||||
const win = BrowserWindow.getFocusedWindow();
|
||||
const win = getMenuTargetWindow();
|
||||
win?.webContents.send('navigate', '/');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Chat',
|
||||
label: labels.navigate.chat,
|
||||
accelerator: 'CmdOrCtrl+2',
|
||||
click: () => {
|
||||
const win = BrowserWindow.getFocusedWindow();
|
||||
win?.webContents.send('navigate', '/chat');
|
||||
const win = getMenuTargetWindow();
|
||||
win?.webContents.send('navigate', '/');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Channels',
|
||||
label: labels.navigate.channels,
|
||||
accelerator: 'CmdOrCtrl+3',
|
||||
click: () => {
|
||||
const win = BrowserWindow.getFocusedWindow();
|
||||
const win = getMenuTargetWindow();
|
||||
win?.webContents.send('navigate', '/channels');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Skills',
|
||||
label: labels.navigate.skills,
|
||||
accelerator: 'CmdOrCtrl+4',
|
||||
click: () => {
|
||||
const win = BrowserWindow.getFocusedWindow();
|
||||
const win = getMenuTargetWindow();
|
||||
win?.webContents.send('navigate', '/skills');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Cron Tasks',
|
||||
label: labels.navigate.cronTasks,
|
||||
accelerator: 'CmdOrCtrl+5',
|
||||
click: () => {
|
||||
const win = BrowserWindow.getFocusedWindow();
|
||||
const win = getMenuTargetWindow();
|
||||
win?.webContents.send('navigate', '/cron');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Settings',
|
||||
label: labels.navigate.settings,
|
||||
accelerator: isMac ? 'Cmd+,' : 'Ctrl+,',
|
||||
click: () => {
|
||||
const win = BrowserWindow.getFocusedWindow();
|
||||
const win = getMenuTargetWindow();
|
||||
win?.webContents.send('navigate', '/settings');
|
||||
},
|
||||
},
|
||||
@@ -154,40 +178,41 @@ export function createMenu(): void {
|
||||
|
||||
// Window menu
|
||||
{
|
||||
label: 'Window',
|
||||
label: labels.window.label,
|
||||
submenu: [
|
||||
{ role: 'minimize' },
|
||||
{ role: 'zoom' },
|
||||
{ role: 'minimize', label: labels.window.minimize },
|
||||
{ role: 'zoom', label: labels.window.zoom },
|
||||
...(isMac
|
||||
? [
|
||||
{ type: 'separator' as const },
|
||||
{ role: 'front' as const },
|
||||
{ role: 'front' as const, label: labels.window.front },
|
||||
{ type: 'separator' as const },
|
||||
{ role: 'window' as const },
|
||||
{ role: 'window' as const, label: labels.window.label },
|
||||
]
|
||||
: [{ role: 'close' as const }]),
|
||||
: [{ role: 'close' as const, label: labels.window.close }]),
|
||||
],
|
||||
},
|
||||
|
||||
// Help menu
|
||||
{
|
||||
role: 'help',
|
||||
label: labels.help.label,
|
||||
submenu: [
|
||||
{
|
||||
label: 'Documentation',
|
||||
label: labels.help.documentation,
|
||||
click: async () => {
|
||||
await shell.openExternal('https://claw-x.com');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Report Issue',
|
||||
label: labels.help.reportIssue,
|
||||
click: async () => {
|
||||
await shell.openExternal('https://github.com/ValueCell-ai/ClawX/issues');
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'OpenClaw Documentation',
|
||||
label: labels.help.openClawDocumentation,
|
||||
click: async () => {
|
||||
await shell.openExternal('https://docs.openclaw.ai');
|
||||
},
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const LOCK_SCHEMA = 'clawx-instance-lock';
|
||||
const LEGACY_LOCK_VERSION = 1;
|
||||
const STRUCTURED_LOCK_VERSION = 2;
|
||||
|
||||
export interface StructuredLockContent {
|
||||
schema: string;
|
||||
version: number;
|
||||
pid: number;
|
||||
ownerToken?: string;
|
||||
appVersion?: string;
|
||||
channel?: string;
|
||||
executable?: string;
|
||||
startedAt?: string;
|
||||
heartbeatAt?: string;
|
||||
}
|
||||
|
||||
export interface ProcessInstanceFileLock {
|
||||
acquired: boolean;
|
||||
lockPath: string;
|
||||
ownerPid?: number;
|
||||
ownerFormat?: 'legacy' | 'structured' | 'unknown';
|
||||
ownerDetails?: StructuredLockContent;
|
||||
release: () => void;
|
||||
}
|
||||
|
||||
export interface ProcessInstanceLockMetadata {
|
||||
appVersion: string;
|
||||
channel: string;
|
||||
executable: string;
|
||||
startedAt?: string;
|
||||
}
|
||||
|
||||
export interface ProcessInstanceFileLockOptions {
|
||||
userDataDir: string;
|
||||
lockName: string;
|
||||
pid?: number;
|
||||
isPidAlive?: (pid: number) => boolean;
|
||||
/** Legacy escape hatch. New shared-data-root callers must not use it. */
|
||||
force?: boolean;
|
||||
lockPath?: string;
|
||||
metadata?: ProcessInstanceLockMetadata;
|
||||
heartbeatIntervalMs?: number;
|
||||
heartbeatExpiryMs?: number;
|
||||
}
|
||||
|
||||
function defaultPidAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return (error as NodeJS.ErrnoException).code !== 'ESRCH';
|
||||
}
|
||||
}
|
||||
|
||||
type ParsedLockOwner =
|
||||
| { kind: 'legacy'; pid: number }
|
||||
| { kind: 'structured'; pid: number; details: StructuredLockContent }
|
||||
| { kind: 'unknown' };
|
||||
|
||||
function parsePositivePid(raw: string): number | undefined {
|
||||
if (!/^\d+$/.test(raw)) return undefined;
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
function parseStructuredLockContent(raw: string): StructuredLockContent | undefined {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<StructuredLockContent>;
|
||||
if (
|
||||
parsed.schema === LOCK_SCHEMA
|
||||
&& (parsed.version === LEGACY_LOCK_VERSION || parsed.version === STRUCTURED_LOCK_VERSION)
|
||||
&& typeof parsed.pid === 'number'
|
||||
&& Number.isFinite(parsed.pid)
|
||||
&& parsed.pid > 0
|
||||
) {
|
||||
return parsed as StructuredLockContent;
|
||||
}
|
||||
} catch {
|
||||
// Unknown content is never removed automatically.
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readLockOwner(lockPath: string): ParsedLockOwner {
|
||||
try {
|
||||
const raw = readFileSync(lockPath, 'utf8').trim();
|
||||
const legacyPid = parsePositivePid(raw);
|
||||
if (legacyPid !== undefined) return { kind: 'legacy', pid: legacyPid };
|
||||
const structured = parseStructuredLockContent(raw);
|
||||
if (structured) return { kind: 'structured', pid: structured.pid, details: structured };
|
||||
} catch {
|
||||
// Missing and unreadable lock files have unknown ownership.
|
||||
}
|
||||
return { kind: 'unknown' };
|
||||
}
|
||||
|
||||
function writeLockAtomic(lockPath: string, content: string): void {
|
||||
const temporaryPath = `${lockPath}.${process.pid}.${randomUUID()}.tmp`;
|
||||
try {
|
||||
writeFileSync(temporaryPath, content, { encoding: 'utf8', mode: 0o600 });
|
||||
renameSync(temporaryPath, lockPath);
|
||||
} catch (error) {
|
||||
rmSync(temporaryPath, { force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function heartbeatExpired(owner: ParsedLockOwner, expiryMs: number): boolean {
|
||||
if (owner.kind !== 'structured') return true;
|
||||
if (!owner.details.heartbeatAt) return true;
|
||||
const heartbeat = Date.parse(owner.details.heartbeatAt);
|
||||
return !Number.isFinite(heartbeat) || Date.now() - heartbeat > expiryMs;
|
||||
}
|
||||
|
||||
export function acquireProcessInstanceFileLock(
|
||||
options: ProcessInstanceFileLockOptions,
|
||||
): ProcessInstanceFileLock {
|
||||
const pid = options.pid ?? process.pid;
|
||||
const isPidAlive = options.isPidAlive ?? defaultPidAlive;
|
||||
const lockPath = options.lockPath ?? join(options.userDataDir, `${options.lockName}.instance.lock`);
|
||||
const heartbeatExpiryMs = options.heartbeatExpiryMs ?? 30_000;
|
||||
mkdirSync(dirname(lockPath), { recursive: true });
|
||||
|
||||
if (options.force && existsSync(lockPath)) {
|
||||
rmSync(lockPath, { force: true });
|
||||
}
|
||||
|
||||
let ownerPid: number | undefined;
|
||||
let ownerFormat: ProcessInstanceFileLock['ownerFormat'] = 'unknown';
|
||||
let ownerDetails: StructuredLockContent | undefined;
|
||||
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
try {
|
||||
const fd = openSync(lockPath, 'wx');
|
||||
const ownerToken = randomUUID();
|
||||
const startedAt = options.metadata?.startedAt ?? new Date().toISOString();
|
||||
const structuredContent: StructuredLockContent | undefined = options.metadata
|
||||
? {
|
||||
schema: LOCK_SCHEMA,
|
||||
version: STRUCTURED_LOCK_VERSION,
|
||||
pid,
|
||||
ownerToken,
|
||||
appVersion: options.metadata.appVersion,
|
||||
channel: options.metadata.channel,
|
||||
executable: options.metadata.executable,
|
||||
startedAt,
|
||||
heartbeatAt: startedAt,
|
||||
}
|
||||
: undefined;
|
||||
try {
|
||||
writeFileSync(fd, structuredContent ? JSON.stringify(structuredContent) : String(pid), 'utf8');
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
|
||||
let released = false;
|
||||
const heartbeatTimer = structuredContent
|
||||
? setInterval(() => {
|
||||
const currentOwner = readLockOwner(lockPath);
|
||||
if (currentOwner.kind !== 'structured' || currentOwner.details.ownerToken !== ownerToken) return;
|
||||
structuredContent.heartbeatAt = new Date().toISOString();
|
||||
try {
|
||||
writeLockAtomic(lockPath, JSON.stringify(structuredContent));
|
||||
} catch {
|
||||
// A missed heartbeat never transfers ownership.
|
||||
}
|
||||
}, options.heartbeatIntervalMs ?? 5_000)
|
||||
: undefined;
|
||||
heartbeatTimer?.unref();
|
||||
|
||||
return {
|
||||
acquired: true,
|
||||
lockPath,
|
||||
release: () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
||||
try {
|
||||
const currentOwner = readLockOwner(lockPath);
|
||||
if (currentOwner.kind === 'unknown' || currentOwner.pid !== pid) return;
|
||||
if (
|
||||
currentOwner.kind === 'structured'
|
||||
&& currentOwner.details.ownerToken
|
||||
&& currentOwner.details.ownerToken !== ownerToken
|
||||
) return;
|
||||
rmSync(lockPath, { force: true });
|
||||
} catch {
|
||||
// Best effort during shutdown.
|
||||
}
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') break;
|
||||
|
||||
const owner = readLockOwner(lockPath);
|
||||
if (owner.kind === 'legacy' || owner.kind === 'structured') {
|
||||
ownerPid = owner.pid;
|
||||
ownerFormat = owner.kind;
|
||||
ownerDetails = owner.kind === 'structured' ? owner.details : undefined;
|
||||
} else {
|
||||
ownerPid = undefined;
|
||||
ownerFormat = 'unknown';
|
||||
ownerDetails = undefined;
|
||||
}
|
||||
|
||||
const stale = (owner.kind === 'legacy' || owner.kind === 'structured')
|
||||
&& !isPidAlive(owner.pid)
|
||||
&& heartbeatExpired(owner, heartbeatExpiryMs);
|
||||
if (stale && existsSync(lockPath)) {
|
||||
try {
|
||||
rmSync(lockPath, { force: true });
|
||||
continue;
|
||||
} catch {
|
||||
// Treat an undeletable stale lock as held.
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
acquired: false,
|
||||
lockPath,
|
||||
ownerPid,
|
||||
ownerFormat,
|
||||
ownerDetails,
|
||||
release: () => {},
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { getProviderConfig } from '../utils/provider-registry';
|
||||
import { getOpenClawProviderKeyForType, isOAuthProviderType } from '../utils/provider-keys';
|
||||
import type { ProviderConfig } from '../utils/secure-storage';
|
||||
import {
|
||||
piAiModelsJsonModelEntry,
|
||||
type PiAiModelCostRates,
|
||||
} from '../shared/pi-ai-model-cost';
|
||||
|
||||
export interface AgentProviderUpdatePayload {
|
||||
providerKey: string;
|
||||
@@ -8,7 +12,7 @@ export interface AgentProviderUpdatePayload {
|
||||
baseUrl: string;
|
||||
api: string;
|
||||
apiKey: string | undefined;
|
||||
models: Array<{ id: string; name: string }>;
|
||||
models: Array<{ id: string; name: string; cost: PiAiModelCostRates }>;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -42,7 +46,7 @@ export function buildNonOAuthAgentProviderUpdate(
|
||||
baseUrl,
|
||||
api,
|
||||
apiKey: meta?.apiKeyEnv,
|
||||
models: modelId ? [{ id: modelId, name: modelId }] : [],
|
||||
models: modelId ? [piAiModelsJsonModelEntry(modelId)] : [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,7 +4,14 @@ import { buildElectronProxyConfig } from '../utils/proxy';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export async function applyProxySettings(
|
||||
partialSettings?: Pick<AppSettings, 'proxyEnabled' | 'proxyServer' | 'proxyBypassRules'>
|
||||
partialSettings?: Pick<AppSettings,
|
||||
| 'proxyEnabled'
|
||||
| 'proxyServer'
|
||||
| 'proxyHttpServer'
|
||||
| 'proxyHttpsServer'
|
||||
| 'proxyAllServer'
|
||||
| 'proxyBypassRules'
|
||||
>,
|
||||
): Promise<void> {
|
||||
const settings = partialSettings ?? await getAllSettings();
|
||||
const config = buildElectronProxyConfig(settings);
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
export interface QuitLifecycleState {
|
||||
cleanupStarted: boolean;
|
||||
cleanupCompleted: boolean;
|
||||
}
|
||||
|
||||
export type QuitLifecycleAction = 'start-cleanup' | 'cleanup-in-progress' | 'allow-quit';
|
||||
|
||||
export function createQuitLifecycleState(): QuitLifecycleState {
|
||||
return {
|
||||
cleanupStarted: false,
|
||||
cleanupCompleted: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function requestQuitLifecycleAction(state: QuitLifecycleState): QuitLifecycleAction {
|
||||
if (state.cleanupCompleted) {
|
||||
return 'allow-quit';
|
||||
}
|
||||
|
||||
if (state.cleanupStarted) {
|
||||
return 'cleanup-in-progress';
|
||||
}
|
||||
|
||||
state.cleanupStarted = true;
|
||||
return 'start-cleanup';
|
||||
}
|
||||
|
||||
export function markQuitCleanupCompleted(state: QuitLifecycleState): void {
|
||||
state.cleanupCompleted = true;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface SignalQuitHandlerHooks {
|
||||
logInfo: (message: string) => void;
|
||||
requestQuit: () => void;
|
||||
}
|
||||
|
||||
export function createSignalQuitHandler(hooks: SignalQuitHandlerHooks): (signal: NodeJS.Signals) => void {
|
||||
return (signal: NodeJS.Signals) => {
|
||||
hooks.logInfo(`Received ${signal}; requesting app quit`);
|
||||
hooks.requestQuit();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { release } from 'node:os';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
|
||||
const MAC_SIDEBAR_CHROME_HEIGHT = 28;
|
||||
const MAC_TRAFFIC_LIGHT_GAP = 8;
|
||||
const MAC_TRAFFIC_LIGHT_FRAME_HEIGHT = 16;
|
||||
const MAC_TRAFFIC_LIGHT_FRAME_HEIGHT_TAHOE = 14;
|
||||
|
||||
function getMacTrafficLightFrameHeight(darwinMajor: number): number {
|
||||
return darwinMajor >= 25
|
||||
? MAC_TRAFFIC_LIGHT_FRAME_HEIGHT_TAHOE
|
||||
: MAC_TRAFFIC_LIGHT_FRAME_HEIGHT;
|
||||
}
|
||||
|
||||
function getMacTrafficLightChromeOffset(buttonFrameHeight: number): number {
|
||||
return Math.floor((MAC_SIDEBAR_CHROME_HEIGHT - buttonFrameHeight) / 2);
|
||||
}
|
||||
|
||||
export function getMacTrafficLightPosition(sidebarCollapsed: boolean): { x: number; y: number } {
|
||||
const darwinMajor = Number.parseInt(release().split('.')[0] ?? '0', 10);
|
||||
const buttonFrameHeight = getMacTrafficLightFrameHeight(darwinMajor);
|
||||
const offset = getMacTrafficLightChromeOffset(buttonFrameHeight);
|
||||
|
||||
if (sidebarCollapsed) {
|
||||
return { x: MAC_TRAFFIC_LIGHT_GAP, y: Math.max(MAC_TRAFFIC_LIGHT_GAP, offset) };
|
||||
}
|
||||
|
||||
return { x: offset + 1, y: offset };
|
||||
}
|
||||
|
||||
export function syncMacTrafficLightPosition(
|
||||
win: BrowserWindow,
|
||||
sidebarCollapsed: boolean,
|
||||
): void {
|
||||
if (process.platform !== 'darwin' || win.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
win.setWindowButtonPosition(getMacTrafficLightPosition(sidebarCollapsed));
|
||||
}
|
||||
@@ -90,20 +90,12 @@ export function createTray(mainWindow: BrowserWindow): Tray {
|
||||
{
|
||||
label: 'Quick Actions',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Open Dashboard',
|
||||
click: () => {
|
||||
if (mainWindow.isDestroyed()) return;
|
||||
mainWindow.show();
|
||||
mainWindow.webContents.send('navigate', '/');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Open Chat',
|
||||
click: () => {
|
||||
if (mainWindow.isDestroyed()) return;
|
||||
mainWindow.show();
|
||||
mainWindow.webContents.send('navigate', '/chat');
|
||||
mainWindow.webContents.send('navigate', '/');
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -60,7 +60,7 @@ export class AppUpdater extends EventEmitter {
|
||||
});
|
||||
|
||||
autoUpdater.autoDownload = false;
|
||||
autoUpdater.autoInstallOnAppQuit = true;
|
||||
autoUpdater.autoInstallOnAppQuit = false;
|
||||
|
||||
autoUpdater.logger = {
|
||||
info: (msg: string) => logger.info('[Updater]', msg),
|
||||
@@ -131,10 +131,6 @@ export class AppUpdater extends EventEmitter {
|
||||
autoUpdater.on('update-downloaded', (event: UpdateDownloadedEvent) => {
|
||||
this.updateStatus({ status: 'downloaded', info: event });
|
||||
this.emit('update-downloaded', event);
|
||||
|
||||
if (autoUpdater.autoDownload) {
|
||||
this.startAutoInstallCountdown();
|
||||
}
|
||||
});
|
||||
|
||||
autoUpdater.on('error', (error: Error) => {
|
||||
@@ -234,7 +230,7 @@ export class AppUpdater extends EventEmitter {
|
||||
* Start a countdown that auto-installs the downloaded update.
|
||||
* Sends `update:auto-install-countdown` events to the renderer each second.
|
||||
*/
|
||||
private startAutoInstallCountdown(): void {
|
||||
startAutoInstallCountdown(): void {
|
||||
this.clearAutoInstallTimer();
|
||||
this.autoInstallCountdown = AppUpdater.AUTO_INSTALL_DELAY_SECONDS;
|
||||
this.sendToRenderer('update:auto-install-countdown', { seconds: this.autoInstallCountdown });
|
||||
@@ -270,10 +266,15 @@ export class AppUpdater extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Set auto-download preference
|
||||
* Set auto-download preference.
|
||||
*
|
||||
* ClawX uses a prompt-first update flow: finding an update shows a UI prompt,
|
||||
* and downloads/installations only start after the user chooses an action.
|
||||
* Keep this legacy IPC method as a no-op-compatible setter so stale renderer
|
||||
* settings cannot re-enable electron-updater's implicit auto-download path.
|
||||
*/
|
||||
setAutoDownload(enable: boolean): void {
|
||||
autoUpdater.autoDownload = enable;
|
||||
setAutoDownload(_enable: boolean): void {
|
||||
autoUpdater.autoDownload = false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import type { Session, WebContents, WebPreferences } from 'electron';
|
||||
import {
|
||||
WEB_BROWSER_INITIAL_URL,
|
||||
WEB_BROWSER_PARTITION,
|
||||
WEB_BROWSER_USER_AGENT,
|
||||
normalizeWebBrowserTopLevelUrl,
|
||||
} from '../../shared/web-browser';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
const DENY_WINDOW_OPEN = { action: 'deny' } as const;
|
||||
|
||||
export class WebBrowserGuestRegistry {
|
||||
private guest: WebContents | null = null;
|
||||
private pendingAttachment = false;
|
||||
|
||||
beginAttachment(): boolean {
|
||||
this.dropDestroyedGuest();
|
||||
if (this.pendingAttachment || this.guest) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.pendingAttachment = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
completeAttachment(guest: WebContents): void {
|
||||
if (!this.pendingAttachment || this.guest) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pendingAttachment = false;
|
||||
this.guest = guest;
|
||||
guest.once('destroyed', () => {
|
||||
if (this.guest === guest) {
|
||||
this.guest = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
cancelAttachment(): void {
|
||||
this.pendingAttachment = false;
|
||||
}
|
||||
|
||||
current(): WebContents | null {
|
||||
this.dropDestroyedGuest();
|
||||
return this.guest;
|
||||
}
|
||||
|
||||
owns(contents: WebContents | null): boolean {
|
||||
return contents !== null && this.current() === contents;
|
||||
}
|
||||
|
||||
hasLiveGuest(): boolean {
|
||||
return this.current() !== null;
|
||||
}
|
||||
|
||||
private dropDestroyedGuest(): void {
|
||||
if (this.guest?.isDestroyed()) {
|
||||
this.guest = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isExpectedWebBrowserAttachment(
|
||||
params: Record<string, unknown>,
|
||||
): boolean {
|
||||
return params.partition === WEB_BROWSER_PARTITION
|
||||
&& params.src === WEB_BROWSER_INITIAL_URL
|
||||
&& params.useragent === WEB_BROWSER_USER_AGENT
|
||||
&& params.allowpopups === true
|
||||
&& params.preload === '';
|
||||
}
|
||||
|
||||
export function hardenWebBrowserPreferences(preferences: WebPreferences): void {
|
||||
delete preferences.preload;
|
||||
preferences.nodeIntegration = false;
|
||||
preferences.nodeIntegrationInSubFrames = false;
|
||||
preferences.nodeIntegrationInWorker = false;
|
||||
preferences.plugins = false;
|
||||
preferences.allowRunningInsecureContent = false;
|
||||
preferences.contextIsolation = true;
|
||||
preferences.sandbox = true;
|
||||
preferences.webSecurity = true;
|
||||
}
|
||||
|
||||
export function installWebBrowserGuestPolicy(
|
||||
embedder: WebContents,
|
||||
options: {
|
||||
browserSession: Session;
|
||||
registry: WebBrowserGuestRegistry;
|
||||
},
|
||||
): () => void {
|
||||
const { browserSession, registry } = options;
|
||||
let attachmentPending = false;
|
||||
let cleanupGuestPolicy: (() => void) | null = null;
|
||||
|
||||
const handleWillAttach = (
|
||||
event: Electron.Event,
|
||||
preferences: WebPreferences,
|
||||
params: Record<string, unknown>,
|
||||
): void => {
|
||||
if (!isExpectedWebBrowserAttachment(params)) {
|
||||
logger.warn('[WebBrowser] Rejected webview attachment with unexpected identity');
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!registry.beginAttachment()) {
|
||||
logger.warn('[WebBrowser] Rejected additional webview attachment');
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
attachmentPending = true;
|
||||
hardenWebBrowserPreferences(preferences);
|
||||
};
|
||||
|
||||
const handleDidAttach = (_event: Electron.Event, guest: WebContents): void => {
|
||||
if (!attachmentPending) {
|
||||
logger.warn('[WebBrowser] Ignored attached guest without a reserved slot');
|
||||
return;
|
||||
}
|
||||
attachmentPending = false;
|
||||
|
||||
if (guest.getType() !== 'webview' || guest.session !== browserSession) {
|
||||
logger.warn('[WebBrowser] Rejected attached guest with unexpected type or session');
|
||||
registry.cancelAttachment();
|
||||
return;
|
||||
}
|
||||
|
||||
registry.completeAttachment(guest);
|
||||
if (!registry.owns(guest)) {
|
||||
logger.warn('[WebBrowser] Failed to register reserved guest');
|
||||
return;
|
||||
}
|
||||
|
||||
guest.setUserAgent(WEB_BROWSER_USER_AGENT);
|
||||
|
||||
const rejectDisallowedNavigation = (
|
||||
details: Electron.Event<Electron.WebContentsWillNavigateEventParams>,
|
||||
): void => {
|
||||
if (!details.isMainFrame || normalizeWebBrowserTopLevelUrl(details.url) !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.warn(`[WebBrowser] Blocked top-level navigation to ${details.url}`);
|
||||
details.preventDefault();
|
||||
};
|
||||
|
||||
const rejectDisallowedRedirect = (
|
||||
details: Electron.Event<Electron.WebContentsWillRedirectEventParams>,
|
||||
): void => {
|
||||
if (!details.isMainFrame || normalizeWebBrowserTopLevelUrl(details.url) !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.warn(`[WebBrowser] Blocked top-level redirect to ${details.url}`);
|
||||
details.preventDefault();
|
||||
};
|
||||
|
||||
// Same-tab fallback cannot preserve window.opener, returned window handles, or full POST/referrer fidelity.
|
||||
guest.setWindowOpenHandler(({ url }) => {
|
||||
const target = normalizeWebBrowserTopLevelUrl(url);
|
||||
if (!target || !registry.owns(guest)) {
|
||||
logger.warn(`[WebBrowser] Blocked popup target ${url}`);
|
||||
return DENY_WINDOW_OPEN;
|
||||
}
|
||||
|
||||
try {
|
||||
void guest.loadURL(target).catch((error) => {
|
||||
logger.warn(`[WebBrowser] Failed to load popup target ${target}:`, error);
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn(`[WebBrowser] Failed to load popup target ${target}:`, error);
|
||||
}
|
||||
|
||||
return DENY_WINDOW_OPEN;
|
||||
});
|
||||
|
||||
let cleaned = false;
|
||||
const cleanup = (): void => {
|
||||
if (cleaned) {
|
||||
return;
|
||||
}
|
||||
cleaned = true;
|
||||
|
||||
guest.off('will-navigate', rejectDisallowedNavigation);
|
||||
guest.off('will-redirect', rejectDisallowedRedirect);
|
||||
guest.off('destroyed', cleanup);
|
||||
if (!guest.isDestroyed()) {
|
||||
guest.setWindowOpenHandler(() => DENY_WINDOW_OPEN);
|
||||
}
|
||||
if (cleanupGuestPolicy === cleanup) {
|
||||
cleanupGuestPolicy = null;
|
||||
}
|
||||
};
|
||||
|
||||
guest.on('will-navigate', rejectDisallowedNavigation);
|
||||
guest.on('will-redirect', rejectDisallowedRedirect);
|
||||
guest.once('destroyed', cleanup);
|
||||
cleanupGuestPolicy = cleanup;
|
||||
};
|
||||
|
||||
embedder.on('will-attach-webview', handleWillAttach);
|
||||
embedder.on('did-attach-webview', handleDidAttach);
|
||||
|
||||
return () => {
|
||||
embedder.off('will-attach-webview', handleWillAttach);
|
||||
embedder.off('did-attach-webview', handleDidAttach);
|
||||
attachmentPending = false;
|
||||
registry.cancelAttachment();
|
||||
cleanupGuestPolicy?.();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import {
|
||||
dialog,
|
||||
session,
|
||||
type BrowserWindow,
|
||||
type MessageBoxOptions,
|
||||
type MessageBoxReturnValue,
|
||||
type Session,
|
||||
} from 'electron';
|
||||
import { WEB_BROWSER_PERMISSION_LABELS } from '@shared/i18n/resources';
|
||||
import { resolveSupportedLanguage } from '@shared/language';
|
||||
import {
|
||||
WEB_BROWSER_PARTITION,
|
||||
WEB_BROWSER_USER_AGENT,
|
||||
} from '@shared/web-browser';
|
||||
import { logger } from '../utils/logger';
|
||||
import { getSetting } from '../utils/store';
|
||||
import type { WebBrowserGuestRegistry } from './web-browser-policy';
|
||||
|
||||
const CLIPBOARD_PERMISSIONS = new Set([
|
||||
'clipboard-read',
|
||||
'clipboard-sanitized-write',
|
||||
'deprecated-sync-clipboard-read',
|
||||
]);
|
||||
const DOWNLOAD_OBSERVED_SESSIONS = new WeakSet<Session>();
|
||||
|
||||
export interface ConfigureWebBrowserSessionOptions {
|
||||
registry: WebBrowserGuestRegistry;
|
||||
getMainWindow: () => BrowserWindow | null;
|
||||
getLanguage?: () => Promise<string | undefined>;
|
||||
showMessageBox?: (
|
||||
window: BrowserWindow,
|
||||
options: MessageBoxOptions,
|
||||
) => Promise<MessageBoxReturnValue>;
|
||||
}
|
||||
|
||||
export function configureWebBrowserSession(
|
||||
options: ConfigureWebBrowserSessionOptions,
|
||||
): Session {
|
||||
const browserSession = session.fromPartition(WEB_BROWSER_PARTITION, { cache: true });
|
||||
const getLanguage = options.getLanguage ?? (() => getSetting('language'));
|
||||
// Resolve the method at request time so Electron E2E tests can replace the native dialog after startup.
|
||||
const showMessageBox = options.showMessageBox
|
||||
?? ((window, messageOptions) => dialog.showMessageBox(window, messageOptions));
|
||||
|
||||
// The macOS UA is fixed on every platform for stable website compatibility and deterministic requests.
|
||||
browserSession.setUserAgent(WEB_BROWSER_USER_AGENT);
|
||||
|
||||
browserSession.setPermissionCheckHandler((_contents, permission) => (
|
||||
CLIPBOARD_PERMISSIONS.has(permission)
|
||||
));
|
||||
|
||||
browserSession.setPermissionRequestHandler((contents, permission, callback, details) => {
|
||||
let callbackCalled = false;
|
||||
const respond = (allowed: boolean): void => {
|
||||
if (callbackCalled) return;
|
||||
callbackCalled = true;
|
||||
callback(allowed);
|
||||
};
|
||||
|
||||
if (CLIPBOARD_PERMISSIONS.has(permission)) {
|
||||
respond(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (permission === 'geolocation') {
|
||||
// ClawX has no location service, so websites cannot receive a meaningful location.
|
||||
respond(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (permission !== 'media' || !options.registry.owns(contents)) {
|
||||
respond(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaDetails = details as Electron.MediaAccessPermissionRequest;
|
||||
const mediaTypes = new Set(mediaDetails.mediaTypes ?? []);
|
||||
const requestsCamera = mediaTypes.has('video');
|
||||
const requestsMicrophone = mediaTypes.has('audio');
|
||||
if (!requestsCamera && !requestsMicrophone) {
|
||||
respond(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const mainWindow = options.getMainWindow();
|
||||
if (!mainWindow) {
|
||||
respond(false);
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const language = resolveSupportedLanguage(await getLanguage());
|
||||
const labels = WEB_BROWSER_PERMISSION_LABELS[language];
|
||||
const capability = requestsCamera && requestsMicrophone
|
||||
? labels.cameraAndMicrophone
|
||||
: requestsCamera
|
||||
? labels.camera
|
||||
: labels.microphone;
|
||||
const origin = mediaDetails.securityOrigin || mediaDetails.requestingUrl;
|
||||
|
||||
if (!options.registry.owns(contents)) {
|
||||
respond(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await showMessageBox(mainWindow, {
|
||||
type: 'question',
|
||||
title: labels.title,
|
||||
message: labels.message
|
||||
.replace('{{origin}}', origin)
|
||||
.replace('{{capability}}', capability),
|
||||
buttons: [labels.allow, labels.deny],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
noLink: true,
|
||||
});
|
||||
respond(result.response === 0 && options.registry.owns(contents));
|
||||
} catch (error) {
|
||||
logger.warn('[WebBrowser] Native media permission dialog failed:', error);
|
||||
respond(false);
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
if (!DOWNLOAD_OBSERVED_SESSIONS.has(browserSession)) {
|
||||
DOWNLOAD_OBSERVED_SESSIONS.add(browserSession);
|
||||
// Preserve Electron's default save location and UI by observing without cancelling or setting a path.
|
||||
browserSession.on('will-download', (_event, item) => {
|
||||
item.once('done', (_doneEvent, state) => {
|
||||
if (state === 'interrupted') {
|
||||
logger.warn('[WebBrowser] Download interrupted');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// This isolated browser Session intentionally does not mirror client proxy settings or recycle connections.
|
||||
return browserSession;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
|
||||
export type ZoomShortcutAction = 'in' | 'out' | 'reset';
|
||||
|
||||
type ZoomShortcutInput = Pick<Electron.Input, 'key' | 'code' | 'control' | 'meta' | 'alt'>;
|
||||
|
||||
export function getZoomShortcutAction(input: ZoomShortcutInput): ZoomShortcutAction | null {
|
||||
if ((!input.control && !input.meta) || input.alt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const key = input.key.toLowerCase();
|
||||
|
||||
if (key === '+' || key === '=' || input.code === 'Equal' || input.code === 'NumpadAdd') {
|
||||
return 'in';
|
||||
}
|
||||
|
||||
if (key === '-' || input.code === 'Minus' || input.code === 'NumpadSubtract') {
|
||||
return 'out';
|
||||
}
|
||||
|
||||
if (key === '0' || input.code === 'Digit0' || input.code === 'Numpad0') {
|
||||
return 'reset';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function registerZoomShortcuts(win: BrowserWindow): void {
|
||||
win.webContents.on('before-input-event', (event, input) => {
|
||||
const action = getZoomShortcutAction(input);
|
||||
|
||||
if (!action) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
if (action === 'reset') {
|
||||
win.webContents.setZoomLevel(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const delta = action === 'in' ? 1 : -1;
|
||||
win.webContents.setZoomLevel(win.webContents.getZoomLevel() + delta);
|
||||
});
|
||||
}
|
||||
+34
-119
@@ -2,7 +2,20 @@
|
||||
* Preload Script
|
||||
* Exposes safe APIs to the renderer process via contextBridge
|
||||
*/
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
import { contextBridge, ipcRenderer, webUtils } from 'electron';
|
||||
import type { HostRequest } from '@shared/host-api/types';
|
||||
import { HOST_EVENT_CHANNELS } from '@shared/host-events/contract';
|
||||
|
||||
const validStaticEventChannels: Set<string> = new Set(
|
||||
Object.values(HOST_EVENT_CHANNELS).flatMap((moduleChannels) => Object.values(moduleChannels)),
|
||||
);
|
||||
const DYNAMIC_CHANNEL_EVENT_RE = /^channel:[a-z0-9_-]+-(?:qr|success|error)$/i;
|
||||
|
||||
function isValidEventChannel(channel: string): boolean {
|
||||
return validStaticEventChannels.has(channel)
|
||||
|| DYNAMIC_CHANNEL_EVENT_RE.test(channel)
|
||||
|| channel.startsWith('ext:');
|
||||
}
|
||||
|
||||
/**
|
||||
* IPC renderer methods exposed to the renderer process
|
||||
@@ -16,39 +29,26 @@ const electronAPI = {
|
||||
const validChannels = [
|
||||
// Gateway
|
||||
'gateway:status',
|
||||
'gateway:isConnected',
|
||||
'gateway:start',
|
||||
'gateway:stop',
|
||||
'gateway:restart',
|
||||
'gateway:rpc',
|
||||
'gateway:httpProxy',
|
||||
'hostapi:fetch',
|
||||
'gateway:health',
|
||||
'gateway:getControlUiUrl',
|
||||
// OpenClaw
|
||||
'openclaw:status',
|
||||
'openclaw:isReady',
|
||||
// Shell
|
||||
'shell:openExternal',
|
||||
'shell:showItemInFolder',
|
||||
'shell:openPath',
|
||||
// Dialog
|
||||
'dialog:open',
|
||||
'dialog:save',
|
||||
'dialog:message',
|
||||
// App
|
||||
'app:version',
|
||||
'app:name',
|
||||
'app:getPath',
|
||||
'app:platform',
|
||||
'app:quit',
|
||||
'app:relaunch',
|
||||
'app:request',
|
||||
// Window controls
|
||||
'window:minimize',
|
||||
'window:maximize',
|
||||
'window:close',
|
||||
'window:isMaximized',
|
||||
'window:syncTrafficLightPosition',
|
||||
// Settings
|
||||
'settings:get',
|
||||
'settings:set',
|
||||
@@ -82,59 +82,14 @@ const electronAPI = {
|
||||
'provider:setDefault',
|
||||
'provider:getDefault',
|
||||
'provider:validateKey',
|
||||
'provider:requestOAuth',
|
||||
'provider:cancelOAuth',
|
||||
// Cron
|
||||
'cron:list',
|
||||
'cron:create',
|
||||
'cron:update',
|
||||
'cron:delete',
|
||||
'cron:toggle',
|
||||
'cron:trigger',
|
||||
// Channel Config
|
||||
'channel:saveConfig',
|
||||
'channel:getConfig',
|
||||
'channel:getFormValues',
|
||||
'channel:deleteConfig',
|
||||
'channel:listConfigured',
|
||||
'channel:setEnabled',
|
||||
'channel:validate',
|
||||
'channel:validate',
|
||||
'channel:validateCredentials',
|
||||
// WhatsApp
|
||||
'channel:requestWhatsAppQr',
|
||||
'channel:cancelWhatsAppQr',
|
||||
// ClawHub
|
||||
'clawhub:search',
|
||||
'clawhub:install',
|
||||
'clawhub:uninstall',
|
||||
'clawhub:list',
|
||||
'clawhub:openSkillReadme',
|
||||
// UV
|
||||
'uv:check',
|
||||
'uv:install-all',
|
||||
// Skill config (direct file access)
|
||||
'skill:updateConfig',
|
||||
'skill:getConfig',
|
||||
'skill:getAllConfigs',
|
||||
// Logs
|
||||
'log:getRecent',
|
||||
'log:readFile',
|
||||
'log:getFilePath',
|
||||
'log:getDir',
|
||||
'log:listFiles',
|
||||
// File staging & media
|
||||
'file:stage',
|
||||
'file:stageBuffer',
|
||||
'media:getThumbnails',
|
||||
'media:saveImage',
|
||||
// Chat send with media (reads staged files in main process)
|
||||
'chat:sendWithMedia',
|
||||
// Session management
|
||||
'session:delete',
|
||||
// File preview (sandboxed read/write/list/tree)
|
||||
'file:readText',
|
||||
'file:readBinary',
|
||||
'file:writeText',
|
||||
'file:stat',
|
||||
'file:listDir',
|
||||
'file:listTree',
|
||||
// OpenClaw extras
|
||||
'openclaw:getDir',
|
||||
'openclaw:getConfigDir',
|
||||
'openclaw:getSkillsDir',
|
||||
'openclaw:getCliCommand',
|
||||
];
|
||||
@@ -150,35 +105,7 @@ const electronAPI = {
|
||||
* Listen for events from main process
|
||||
*/
|
||||
on: (channel: string, callback: (...args: unknown[]) => void) => {
|
||||
const validChannels = [
|
||||
'gateway:status-changed',
|
||||
'gateway:message',
|
||||
'gateway:notification',
|
||||
'gateway:channel-status',
|
||||
'gateway:chat-message',
|
||||
'channel:whatsapp-qr',
|
||||
'channel:whatsapp-success',
|
||||
'channel:whatsapp-error',
|
||||
'gateway:exit',
|
||||
'gateway:error',
|
||||
'navigate',
|
||||
'update:status-changed',
|
||||
'update:checking',
|
||||
'update:available',
|
||||
'update:not-available',
|
||||
'update:progress',
|
||||
'update:downloaded',
|
||||
'update:error',
|
||||
'update:auto-install-countdown',
|
||||
'cron:updated',
|
||||
'oauth:code',
|
||||
'oauth:success',
|
||||
'oauth:error',
|
||||
'openclaw:cli-installed',
|
||||
];
|
||||
|
||||
if (validChannels.includes(channel)) {
|
||||
// Wrap the callback to strip the event
|
||||
if (isValidEventChannel(channel)) {
|
||||
const subscription = (_event: Electron.IpcRendererEvent, ...args: unknown[]) => {
|
||||
callback(...args);
|
||||
};
|
||||
@@ -197,29 +124,7 @@ const electronAPI = {
|
||||
* Listen for a single event from main process
|
||||
*/
|
||||
once: (channel: string, callback: (...args: unknown[]) => void) => {
|
||||
const validChannels = [
|
||||
'gateway:status-changed',
|
||||
'gateway:message',
|
||||
'gateway:notification',
|
||||
'gateway:channel-status',
|
||||
'gateway:chat-message',
|
||||
'gateway:exit',
|
||||
'gateway:error',
|
||||
'navigate',
|
||||
'update:status-changed',
|
||||
'update:checking',
|
||||
'update:available',
|
||||
'update:not-available',
|
||||
'update:progress',
|
||||
'update:downloaded',
|
||||
'update:error',
|
||||
'update:auto-install-countdown',
|
||||
'oauth:code',
|
||||
'oauth:success',
|
||||
'oauth:error',
|
||||
];
|
||||
|
||||
if (validChannels.includes(channel)) {
|
||||
if (isValidEventChannel(channel)) {
|
||||
ipcRenderer.once(channel, (_event, ...args) => callback(...args));
|
||||
return;
|
||||
}
|
||||
@@ -247,6 +152,11 @@ const electronAPI = {
|
||||
return ipcRenderer.invoke('shell:openExternal', url);
|
||||
},
|
||||
|
||||
/**
|
||||
* Resolve the on-disk path for a native drag/drop or <input type="file"> File.
|
||||
*/
|
||||
getPathForFile: (file: File) => webUtils.getPathForFile(file),
|
||||
|
||||
/**
|
||||
* Get current platform
|
||||
*/
|
||||
@@ -258,8 +168,13 @@ const electronAPI = {
|
||||
isDev: process.env.NODE_ENV === 'development' || !!process.env.VITE_DEV_SERVER_URL,
|
||||
};
|
||||
|
||||
const clawxAPI = {
|
||||
hostInvoke: (request: HostRequest) => ipcRenderer.invoke('host:invoke', request),
|
||||
};
|
||||
|
||||
// Expose the API to the renderer process
|
||||
contextBridge.exposeInMainWorld('electron', electronAPI);
|
||||
contextBridge.exposeInMainWorld('clawx', clawxAPI);
|
||||
|
||||
// Type declarations for the renderer process
|
||||
export type ElectronAPI = typeof electronAPI;
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { app } from 'electron';
|
||||
import { getClawXDataLayout, resolveClawXDataRoot } from '../utils/clawx-data-layout';
|
||||
|
||||
export type CcConnectPermissionMode = 'suggest' | 'full-auto';
|
||||
|
||||
type AgentBinding = {
|
||||
providerAccountId?: string;
|
||||
permissionMode?: CcConnectPermissionMode;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type AgentBindingDocument = {
|
||||
schema: 'clawx-agent-bindings';
|
||||
version: 1;
|
||||
agents: Record<string, AgentBinding>;
|
||||
};
|
||||
|
||||
function bindingsPath(): string {
|
||||
const layout = getClawXDataLayout(resolveClawXDataRoot(process.env, app.getPath('userData')));
|
||||
return join(layout.appDir, 'agent-bindings.json');
|
||||
}
|
||||
|
||||
async function readDocument(): Promise<AgentBindingDocument> {
|
||||
try {
|
||||
const parsed = JSON.parse(await readFile(bindingsPath(), 'utf8')) as Partial<AgentBindingDocument>;
|
||||
if (parsed.schema === 'clawx-agent-bindings' && parsed.version === 1 && parsed.agents) {
|
||||
return parsed as AgentBindingDocument;
|
||||
}
|
||||
} catch {
|
||||
// Missing or malformed bindings start empty and are replaced atomically on write.
|
||||
}
|
||||
return { schema: 'clawx-agent-bindings', version: 1, agents: {} };
|
||||
}
|
||||
|
||||
async function writeDocument(document: AgentBindingDocument): Promise<void> {
|
||||
const path = bindingsPath();
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
||||
await writeFile(temporaryPath, `${JSON.stringify(document, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
||||
await rename(temporaryPath, path);
|
||||
}
|
||||
|
||||
export async function listCcConnectAgentProviderBindings(): Promise<Record<string, string>> {
|
||||
const document = await readDocument();
|
||||
return Object.fromEntries(Object.entries(document.agents).flatMap(([agentId, binding]) => (
|
||||
binding.providerAccountId ? [[agentId, binding.providerAccountId]] : []
|
||||
)));
|
||||
}
|
||||
|
||||
export async function listCcConnectAgentPermissionModes(): Promise<Record<string, CcConnectPermissionMode>> {
|
||||
const document = await readDocument();
|
||||
return Object.fromEntries(Object.entries(document.agents).flatMap(([agentId, binding]) => (
|
||||
binding.permissionMode === 'suggest' || binding.permissionMode === 'full-auto'
|
||||
? [[agentId, binding.permissionMode]]
|
||||
: []
|
||||
)));
|
||||
}
|
||||
|
||||
export async function setCcConnectAgentProviderBinding(
|
||||
agentId: string,
|
||||
providerAccountId: string | null,
|
||||
): Promise<void> {
|
||||
const normalizedAgentId = agentId.trim();
|
||||
if (!normalizedAgentId) throw new Error('agentId is required');
|
||||
const document = await readDocument();
|
||||
const normalizedAccountId = providerAccountId?.trim();
|
||||
if (normalizedAccountId) {
|
||||
document.agents[normalizedAgentId] = {
|
||||
...document.agents[normalizedAgentId],
|
||||
providerAccountId: normalizedAccountId,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
} else {
|
||||
const existing = document.agents[normalizedAgentId];
|
||||
if (existing?.permissionMode) {
|
||||
document.agents[normalizedAgentId] = {
|
||||
permissionMode: existing.permissionMode,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
} else {
|
||||
delete document.agents[normalizedAgentId];
|
||||
}
|
||||
}
|
||||
await writeDocument(document);
|
||||
}
|
||||
|
||||
export async function setCcConnectAgentPermissionMode(
|
||||
agentId: string,
|
||||
permissionMode: CcConnectPermissionMode,
|
||||
): Promise<void> {
|
||||
const normalizedAgentId = agentId.trim();
|
||||
if (!normalizedAgentId) throw new Error('agentId is required');
|
||||
if (permissionMode !== 'suggest' && permissionMode !== 'full-auto') {
|
||||
throw new Error('permissionMode must be suggest or full-auto');
|
||||
}
|
||||
const document = await readDocument();
|
||||
document.agents[normalizedAgentId] = {
|
||||
...document.agents[normalizedAgentId],
|
||||
permissionMode,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await writeDocument(document);
|
||||
}
|
||||
|
||||
export async function deleteCcConnectAgentBinding(agentId: string): Promise<void> {
|
||||
const normalizedAgentId = agentId.trim();
|
||||
if (!normalizedAgentId) return;
|
||||
const document = await readDocument();
|
||||
if (!(normalizedAgentId in document.agents)) return;
|
||||
delete document.agents[normalizedAgentId];
|
||||
await writeDocument(document);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
import { chmod, mkdir, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { getCcConnectManagedDir } from './cc-connect-paths';
|
||||
|
||||
function safeName(value: string): string {
|
||||
return encodeURIComponent(value.trim() || 'default').replace(/%/g, '_');
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
export async function ensureCcConnectCodexLauncher(options: {
|
||||
accountId: string;
|
||||
codexHomeDir: string;
|
||||
codexPath: string;
|
||||
envAliases?: Record<string, string>;
|
||||
}): Promise<string> {
|
||||
const launchersDir = join(getCcConnectManagedDir(), 'config', 'launchers');
|
||||
await mkdir(launchersDir, { recursive: true });
|
||||
const baseName = `codex-${safeName(options.accountId)}`;
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const path = join(launchersDir, `${baseName}.cmd`);
|
||||
const content = [
|
||||
'@echo off',
|
||||
`set "CODEX_HOME=${options.codexHomeDir}"`,
|
||||
...Object.entries(options.envAliases ?? {}).map(([target, source]) => `set "${target}=%${source}%"`),
|
||||
`"${options.codexPath.replace(/"/g, '""')}" %*`,
|
||||
'',
|
||||
].join('\r\n');
|
||||
await writeFile(path, content, { encoding: 'utf8', mode: 0o700 });
|
||||
return path;
|
||||
}
|
||||
|
||||
const path = join(launchersDir, baseName);
|
||||
const content = [
|
||||
'#!/bin/sh',
|
||||
`export CODEX_HOME=${shellQuote(options.codexHomeDir)}`,
|
||||
...Object.entries(options.envAliases ?? {}).map(([target, source]) => `export ${target}="\${${source}}"`),
|
||||
`exec ${shellQuote(options.codexPath)} "$@"`,
|
||||
'',
|
||||
].join('\n');
|
||||
await writeFile(path, content, { encoding: 'utf8', mode: 0o700 });
|
||||
await chmod(path, 0o700);
|
||||
return path;
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
import { readdir, readFile, stat } from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
import type { RawMessage } from '@shared/chat/types';
|
||||
|
||||
const MAX_TRANSCRIPT_SEARCH_DEPTH = 6;
|
||||
const MAX_TOOL_OUTPUT_CHARS = 16_000;
|
||||
const TRANSCRIPT_TURN_MATCH_WINDOW_MS = 2 * 60_000;
|
||||
const MAX_TRANSCRIPT_FILE_CACHE_ENTRIES = 512;
|
||||
const MAX_TRANSCRIPT_PATH_CACHE_ENTRIES = 2_048;
|
||||
const MAX_FALLBACK_TURN_HINTS = 20;
|
||||
const MAX_FALLBACK_DIRECTORIES = 12;
|
||||
const MAX_FALLBACK_CANDIDATE_FILES = 64;
|
||||
const MAX_FALLBACK_FILE_BYTES = 8 * 1024 * 1024;
|
||||
const MAX_FALLBACK_TOTAL_BYTES = 32 * 1024 * 1024;
|
||||
type CachedTranscriptFile = {
|
||||
mtimeMs: number;
|
||||
size: number;
|
||||
jsonl: string;
|
||||
turnMetadata?: {
|
||||
sessionTimestamp?: number;
|
||||
sessionWorkDir?: string;
|
||||
userTurns: Array<{
|
||||
content: string;
|
||||
timestamp?: number;
|
||||
}>;
|
||||
};
|
||||
toolMessages?: RawMessage[];
|
||||
};
|
||||
|
||||
const transcriptFileCache = new Map<string, CachedTranscriptFile>();
|
||||
const transcriptPathBySessionId = new Map<string, string>();
|
||||
|
||||
function setBoundedCache<K, V>(cache: Map<K, V>, key: K, value: V, maxEntries: number): void {
|
||||
cache.delete(key);
|
||||
cache.set(key, value);
|
||||
while (cache.size > maxEntries) {
|
||||
const oldestKey = cache.keys().next().value;
|
||||
if (oldestKey === undefined) break;
|
||||
cache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
export type CcConnectTranscriptTurnHint = {
|
||||
content: string;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function parseTimestamp(value: unknown): number | undefined {
|
||||
if (typeof value !== 'string' || !value.trim()) return undefined;
|
||||
const timestamp = Date.parse(value);
|
||||
return Number.isFinite(timestamp) ? timestamp : undefined;
|
||||
}
|
||||
|
||||
function parseToolArguments(value: unknown): unknown {
|
||||
if (typeof value !== 'string') return value ?? {};
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return {};
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
function displayToolName(name: string): string {
|
||||
switch (name) {
|
||||
case 'exec_command':
|
||||
return 'Bash';
|
||||
case 'apply_patch':
|
||||
return 'Patch';
|
||||
case 'web_search':
|
||||
case 'web_search_call':
|
||||
return 'Web Search';
|
||||
default:
|
||||
return name || 'tool';
|
||||
}
|
||||
}
|
||||
|
||||
function toolOutputIsError(output: string): boolean {
|
||||
const exitCode = output.match(/\bProcess exited with code (\d+)\b/i)?.[1];
|
||||
return exitCode !== undefined && Number(exitCode) !== 0;
|
||||
}
|
||||
|
||||
function toolOutputText(value: unknown): string {
|
||||
if (typeof value === 'string') return value;
|
||||
return JSON.stringify(value ?? '');
|
||||
}
|
||||
|
||||
function truncateToolOutput(output: string): string {
|
||||
return output.length > MAX_TOOL_OUTPUT_CHARS
|
||||
? `${output.slice(0, MAX_TOOL_OUTPUT_CHARS)}\n… output truncated by ClawX`
|
||||
: output;
|
||||
}
|
||||
|
||||
async function readTranscriptFile(path: string): Promise<CachedTranscriptFile | null> {
|
||||
const metadata = await stat(path).catch(() => null);
|
||||
if (!metadata) return null;
|
||||
const cached = transcriptFileCache.get(path);
|
||||
if (cached && cached.mtimeMs === metadata.mtimeMs && cached.size === metadata.size) {
|
||||
setBoundedCache(transcriptFileCache, path, cached, MAX_TRANSCRIPT_FILE_CACHE_ENTRIES);
|
||||
return cached;
|
||||
}
|
||||
const jsonl = await readFile(path, 'utf8').catch(() => '');
|
||||
const entry = {
|
||||
mtimeMs: metadata.mtimeMs,
|
||||
size: metadata.size,
|
||||
jsonl,
|
||||
};
|
||||
setBoundedCache(transcriptFileCache, path, entry, MAX_TRANSCRIPT_FILE_CACHE_ENTRIES);
|
||||
return entry;
|
||||
}
|
||||
|
||||
async function findTranscriptFile(
|
||||
directory: string,
|
||||
agentSessionId: string,
|
||||
depth = 0,
|
||||
): Promise<string | undefined> {
|
||||
if (depth > MAX_TRANSCRIPT_SEARCH_DEPTH) return undefined;
|
||||
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
if (entry.name.endsWith('.jsonl') && entry.name.includes(agentSessionId)) {
|
||||
return join(directory, entry.name);
|
||||
}
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const match = await findTranscriptFile(join(directory, entry.name), agentSessionId, depth + 1);
|
||||
if (match) return match;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function transcriptDateParts(timestamp: number, utc: boolean): [string, string, string] {
|
||||
const date = new Date(timestamp);
|
||||
const year = utc ? date.getUTCFullYear() : date.getFullYear();
|
||||
const month = (utc ? date.getUTCMonth() : date.getMonth()) + 1;
|
||||
const day = utc ? date.getUTCDate() : date.getDate();
|
||||
return [String(year), String(month).padStart(2, '0'), String(day).padStart(2, '0')];
|
||||
}
|
||||
|
||||
function transcriptCandidateDateParts(timestamp: number): Array<[string, string, string]> {
|
||||
const candidates = [
|
||||
transcriptDateParts(timestamp - 24 * 60 * 60_000, false),
|
||||
transcriptDateParts(timestamp, false),
|
||||
transcriptDateParts(timestamp + 24 * 60 * 60_000, false),
|
||||
transcriptDateParts(timestamp, true),
|
||||
];
|
||||
return Array.from(new Map(candidates.map((parts) => [parts.join('/'), parts])).values());
|
||||
}
|
||||
|
||||
function transcriptTurnMetadata(file: CachedTranscriptFile): NonNullable<CachedTranscriptFile['turnMetadata']> {
|
||||
if (file.turnMetadata) return file.turnMetadata;
|
||||
let sessionTimestamp: number | undefined;
|
||||
let sessionWorkDir: string | undefined;
|
||||
const userTurns: NonNullable<CachedTranscriptFile['turnMetadata']>['userTurns'] = [];
|
||||
for (const line of file.jsonl.split(/\r?\n/)) {
|
||||
if (!line.trim()) continue;
|
||||
let record: Record<string, unknown>;
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
if (!isRecord(parsed)) continue;
|
||||
record = parsed;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (record.type === 'session_meta' && isRecord(record.payload)) {
|
||||
sessionTimestamp = parseTimestamp(record.payload.timestamp) ?? parseTimestamp(record.timestamp);
|
||||
sessionWorkDir = typeof record.payload.cwd === 'string' ? record.payload.cwd : undefined;
|
||||
continue;
|
||||
}
|
||||
if (record.type !== 'response_item' || !isRecord(record.payload)) continue;
|
||||
const payload = record.payload;
|
||||
if (payload.type !== 'message' || payload.role !== 'user' || !Array.isArray(payload.content)) continue;
|
||||
const timestamp = parseTimestamp(record.timestamp) ?? sessionTimestamp;
|
||||
for (const item of payload.content) {
|
||||
if (!isRecord(item) || item.type !== 'input_text' || typeof item.text !== 'string') continue;
|
||||
userTurns.push({
|
||||
content: item.text.trim(),
|
||||
...(timestamp !== undefined ? { timestamp } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
file.turnMetadata = { sessionTimestamp, sessionWorkDir, userTurns };
|
||||
return file.turnMetadata;
|
||||
}
|
||||
|
||||
function transcriptMatchesWorkDir(file: CachedTranscriptFile, expectedWorkDir?: string): boolean {
|
||||
if (!expectedWorkDir) return true;
|
||||
const { sessionWorkDir } = transcriptTurnMetadata(file);
|
||||
return sessionWorkDir !== undefined && resolve(sessionWorkDir) === resolve(expectedWorkDir);
|
||||
}
|
||||
|
||||
function transcriptMatchesTurn(
|
||||
file: CachedTranscriptFile,
|
||||
hints: CcConnectTranscriptTurnHint[],
|
||||
expectedWorkDir?: string,
|
||||
): boolean {
|
||||
const { userTurns } = transcriptTurnMetadata(file);
|
||||
if (userTurns.length === 0 || !transcriptMatchesWorkDir(file, expectedWorkDir)) return false;
|
||||
return hints.some((hint) => userTurns.some((turn) => (
|
||||
turn.timestamp !== undefined
|
||||
&& Math.abs(turn.timestamp - hint.timestamp) <= TRANSCRIPT_TURN_MATCH_WINDOW_MS
|
||||
&& turn.content === hint.content.trim()
|
||||
)));
|
||||
}
|
||||
|
||||
async function findTurnTranscriptFiles(
|
||||
codexHomeDir: string,
|
||||
hints: CcConnectTranscriptTurnHint[],
|
||||
expectedWorkDir?: string,
|
||||
): Promise<string[]> {
|
||||
const sessionRoot = join(codexHomeDir, 'sessions');
|
||||
const directories = new Map<string, string>();
|
||||
const recentHints = [...hints]
|
||||
.sort((left, right) => right.timestamp - left.timestamp)
|
||||
.slice(0, MAX_FALLBACK_TURN_HINTS);
|
||||
for (const hint of recentHints) {
|
||||
for (const parts of transcriptCandidateDateParts(hint.timestamp)) {
|
||||
const directory = join(sessionRoot, ...parts);
|
||||
directories.set(directory, directory);
|
||||
if (directories.size >= MAX_FALLBACK_DIRECTORIES) break;
|
||||
}
|
||||
if (directories.size >= MAX_FALLBACK_DIRECTORIES) break;
|
||||
}
|
||||
const matches: string[] = [];
|
||||
let candidateFiles = 0;
|
||||
let candidateBytes = 0;
|
||||
for (const directory of directories.values()) {
|
||||
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
|
||||
const transcriptEntries = entries
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl'))
|
||||
.sort((left, right) => right.name.localeCompare(left.name));
|
||||
for (const entry of transcriptEntries) {
|
||||
if (candidateFiles >= MAX_FALLBACK_CANDIDATE_FILES) return matches;
|
||||
candidateFiles += 1;
|
||||
const path = join(directory, entry.name);
|
||||
const metadata = await stat(path).catch(() => null);
|
||||
if (!metadata || metadata.size > MAX_FALLBACK_FILE_BYTES) continue;
|
||||
if (candidateBytes + metadata.size > MAX_FALLBACK_TOTAL_BYTES) return matches;
|
||||
candidateBytes += metadata.size;
|
||||
const file = await readTranscriptFile(path);
|
||||
if (file?.jsonl && transcriptMatchesTurn(file, recentHints, expectedWorkDir)) matches.push(path);
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
export function parseCcConnectCodexTranscriptTools(jsonl: string): RawMessage[] {
|
||||
const messages: RawMessage[] = [];
|
||||
const toolNamesByCallId = new Map<string, string>();
|
||||
|
||||
for (const line of jsonl.split(/\r?\n/)) {
|
||||
if (!line.trim()) continue;
|
||||
let record: Record<string, unknown>;
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
if (!isRecord(parsed)) continue;
|
||||
record = parsed;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (record.type !== 'response_item' || !isRecord(record.payload)) continue;
|
||||
const payload = record.payload;
|
||||
const payloadType = typeof payload.type === 'string' ? payload.type : '';
|
||||
const callId = typeof payload.call_id === 'string'
|
||||
? payload.call_id.trim()
|
||||
: typeof payload.id === 'string'
|
||||
? payload.id.trim()
|
||||
: '';
|
||||
if (!callId) continue;
|
||||
const timestamp = parseTimestamp(record.timestamp);
|
||||
|
||||
if (payloadType === 'function_call' || payloadType === 'custom_tool_call') {
|
||||
const rawName = typeof payload.name === 'string' ? payload.name.trim() : '';
|
||||
const name = displayToolName(rawName);
|
||||
toolNamesByCallId.set(callId, name);
|
||||
messages.push({
|
||||
id: `cc-connect-codex-tool-${callId}`,
|
||||
role: 'assistant',
|
||||
content: [{
|
||||
type: 'toolCall',
|
||||
id: callId,
|
||||
name,
|
||||
arguments: parseToolArguments(payload.arguments ?? payload.input),
|
||||
}],
|
||||
...(timestamp !== undefined ? { timestamp } : {}),
|
||||
stopReason: 'tool_use',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (payloadType === 'function_call_output' || payloadType === 'custom_tool_call_output') {
|
||||
const rawOutput = toolOutputText(payload.output ?? payload.content);
|
||||
const output = truncateToolOutput(rawOutput);
|
||||
const name = toolNamesByCallId.get(callId) || 'tool';
|
||||
const isError = toolOutputIsError(rawOutput);
|
||||
messages.push({
|
||||
id: `cc-connect-codex-tool-result-${callId}`,
|
||||
role: 'toolresult',
|
||||
toolCallId: callId,
|
||||
toolName: name,
|
||||
content: output,
|
||||
details: {
|
||||
status: isError ? 'error' : 'completed',
|
||||
aggregated: output,
|
||||
},
|
||||
...(isError ? { isError: true } : {}),
|
||||
...(timestamp !== undefined ? { timestamp } : {}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (payloadType === 'web_search_call') {
|
||||
const name = 'Web Search';
|
||||
messages.push({
|
||||
id: `cc-connect-codex-tool-${callId}`,
|
||||
role: 'assistant',
|
||||
content: [{
|
||||
type: 'toolCall',
|
||||
id: callId,
|
||||
name,
|
||||
arguments: payload.action ?? {},
|
||||
}],
|
||||
...(timestamp !== undefined ? { timestamp } : {}),
|
||||
stopReason: 'tool_use',
|
||||
});
|
||||
const status = typeof payload.status === 'string' ? payload.status.toLowerCase() : '';
|
||||
const isError = ['cancelled', 'error', 'failed'].includes(status);
|
||||
if (status === 'completed' || isError) {
|
||||
const output = isError ? `Web search ${status}` : 'Web search completed';
|
||||
messages.push({
|
||||
id: `cc-connect-codex-tool-result-${callId}`,
|
||||
role: 'toolresult',
|
||||
toolCallId: callId,
|
||||
toolName: name,
|
||||
content: output,
|
||||
details: {
|
||||
status: isError ? 'error' : 'completed',
|
||||
aggregated: output,
|
||||
},
|
||||
...(isError ? { isError: true } : {}),
|
||||
...(timestamp !== undefined ? { timestamp } : {}),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (payloadType === 'mcp_tool_call') {
|
||||
const server = typeof payload.server === 'string' ? payload.server : '';
|
||||
const tool = typeof payload.tool === 'string'
|
||||
? payload.tool
|
||||
: typeof payload.name === 'string'
|
||||
? payload.name
|
||||
: 'tool';
|
||||
const name = server ? `${server}: ${tool}` : tool;
|
||||
messages.push({
|
||||
id: `cc-connect-codex-tool-${callId}`,
|
||||
role: 'assistant',
|
||||
content: [{
|
||||
type: 'toolCall',
|
||||
id: callId,
|
||||
name,
|
||||
arguments: parseToolArguments(payload.arguments ?? payload.input),
|
||||
}],
|
||||
...(timestamp !== undefined ? { timestamp } : {}),
|
||||
stopReason: 'tool_use',
|
||||
});
|
||||
if (payload.result !== undefined || payload.error !== undefined) {
|
||||
const isError = payload.error !== undefined;
|
||||
const output = truncateToolOutput(toolOutputText(payload.error ?? payload.result));
|
||||
messages.push({
|
||||
id: `cc-connect-codex-tool-result-${callId}`,
|
||||
role: 'toolresult',
|
||||
toolCallId: callId,
|
||||
toolName: name,
|
||||
content: output,
|
||||
details: {
|
||||
status: isError ? 'error' : 'completed',
|
||||
aggregated: output,
|
||||
},
|
||||
...(isError ? { isError: true } : {}),
|
||||
...(timestamp !== undefined ? { timestamp } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
export async function loadCcConnectCodexTranscriptTools(
|
||||
codexHomeDirs: string | Iterable<string>,
|
||||
agentSessionId: string,
|
||||
turnHints: CcConnectTranscriptTurnHint[] = [],
|
||||
expectedWorkDir?: string,
|
||||
): Promise<RawMessage[]> {
|
||||
const hasValidAgentSessionId = /^[A-Za-z0-9_-]+$/.test(agentSessionId);
|
||||
if (!hasValidAgentSessionId && turnHints.length === 0) return [];
|
||||
const homes = typeof codexHomeDirs === 'string'
|
||||
? [codexHomeDirs]
|
||||
: Array.from(codexHomeDirs);
|
||||
const uniqueHomes = Array.from(new Set(homes.filter(Boolean)));
|
||||
const idMatchedPaths = new Set<string>();
|
||||
for (const codexHomeDir of uniqueHomes) {
|
||||
if (hasValidAgentSessionId) {
|
||||
const sessionPathCacheKey = `${resolve(codexHomeDir)}\0${agentSessionId}`;
|
||||
let transcriptPath = transcriptPathBySessionId.get(sessionPathCacheKey);
|
||||
if (!transcriptPath) {
|
||||
transcriptPath = await findTranscriptFile(join(codexHomeDir, 'sessions'), agentSessionId);
|
||||
}
|
||||
if (transcriptPath) {
|
||||
setBoundedCache(
|
||||
transcriptPathBySessionId,
|
||||
sessionPathCacheKey,
|
||||
transcriptPath,
|
||||
MAX_TRANSCRIPT_PATH_CACHE_ENTRIES,
|
||||
);
|
||||
const file = await readTranscriptFile(transcriptPath);
|
||||
const matchesPublicTurn = turnHints.length === 0
|
||||
|| (file !== null && transcriptMatchesTurn(file, turnHints, expectedWorkDir));
|
||||
if (file?.jsonl && transcriptMatchesWorkDir(file, expectedWorkDir) && matchesPublicTurn) {
|
||||
idMatchedPaths.add(transcriptPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let transcriptPaths = new Set<string>();
|
||||
if (idMatchedPaths.size === 1) {
|
||||
transcriptPaths = idMatchedPaths;
|
||||
} else if (idMatchedPaths.size === 0) {
|
||||
const fallbackPaths = new Set<string>();
|
||||
for (const codexHomeDir of uniqueHomes) {
|
||||
for (const path of await findTurnTranscriptFiles(codexHomeDir, turnHints, expectedWorkDir)) {
|
||||
fallbackPaths.add(path);
|
||||
}
|
||||
}
|
||||
if (fallbackPaths.size === 1) transcriptPaths = fallbackPaths;
|
||||
}
|
||||
const messages: RawMessage[] = [];
|
||||
for (const transcriptPath of transcriptPaths) {
|
||||
const file = await readTranscriptFile(transcriptPath);
|
||||
if (!file?.jsonl) continue;
|
||||
file.toolMessages ??= parseCcConnectCodexTranscriptTools(file.jsonl);
|
||||
messages.push(...file.toolMessages);
|
||||
}
|
||||
return messages.sort((left, right) => (left.timestamp ?? 0) - (right.timestamp ?? 0));
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export const CC_CONNECT_MANAGEMENT_PORT = 9820;
|
||||
|
||||
export function buildCcConnectWebAdminUrl(port = CC_CONNECT_MANAGEMENT_PORT): string {
|
||||
const normalizedPort = Number.isFinite(port) && port > 0
|
||||
? Math.trunc(port)
|
||||
: CC_CONNECT_MANAGEMENT_PORT;
|
||||
return `http://127.0.0.1:${normalizedPort}/`;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { app } from 'electron';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { getClawXDataLayout, resolveClawXDataRoot } from '../utils/clawx-data-layout';
|
||||
|
||||
function binaryName(): string {
|
||||
return process.platform === 'win32' ? 'cc-connect.exe' : 'cc-connect';
|
||||
}
|
||||
|
||||
export function getCcConnectManagedDir(): string {
|
||||
return getClawXDataLayout(resolveClawXDataRoot(process.env, app.getPath('userData'))).ccConnectRuntimeDir;
|
||||
}
|
||||
|
||||
export function getCcConnectConfigPath(): string {
|
||||
return join(getCcConnectManagedDir(), 'config.toml');
|
||||
}
|
||||
|
||||
export function getCcConnectCodexHomeDir(): string {
|
||||
return join(getCcConnectManagedDir(), 'codex-home');
|
||||
}
|
||||
|
||||
export function getCcConnectAccountCodexHomeDir(accountId: string): string {
|
||||
const normalized = accountId.trim() || 'default';
|
||||
const safeAccountId = encodeURIComponent(normalized).replace(/%/g, '_');
|
||||
const layout = getClawXDataLayout(resolveClawXDataRoot(process.env, app.getPath('userData')));
|
||||
return join(layout.credentialsDir, 'oauth', safeAccountId, 'codex-home');
|
||||
}
|
||||
|
||||
export function getCcConnectWorkspacesDir(): string {
|
||||
return getClawXDataLayout(resolveClawXDataRoot(process.env, app.getPath('userData'))).agentWorkspacesDir;
|
||||
}
|
||||
|
||||
export function getCcConnectAgentWorkspaceDir(agentId = 'main'): string {
|
||||
const safeAgentId = agentId.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-') || 'main';
|
||||
return join(getCcConnectWorkspacesDir(), safeAgentId);
|
||||
}
|
||||
|
||||
export function getCcConnectProviderProfilePath(): string {
|
||||
return join(getCcConnectManagedDir(), 'provider-profile.json');
|
||||
}
|
||||
|
||||
export function getCcConnectBinaryPath(): string {
|
||||
if (!app.isPackaged && process.env.CLAWX_CC_CONNECT_PATH) {
|
||||
return process.env.CLAWX_CC_CONNECT_PATH;
|
||||
}
|
||||
if (app.isPackaged) {
|
||||
return join(process.resourcesPath, 'cc-connect', binaryName());
|
||||
}
|
||||
const bundledDevBinary = join(process.cwd(), 'build', 'cc-connect', `${process.platform}-${process.arch}`, binaryName());
|
||||
if (existsSync(bundledDevBinary)) {
|
||||
return bundledDevBinary;
|
||||
}
|
||||
return bundledDevBinary;
|
||||
}
|
||||
|
||||
export function assertCcConnectBinaryPath(candidate = getCcConnectBinaryPath()): string {
|
||||
if (!existsSync(candidate)) {
|
||||
throw new Error(
|
||||
`cc-connect binary not found at ${candidate}. Run pnpm run bundle:cc-connect:current before selecting cc-connect runtime.`,
|
||||
);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
@@ -0,0 +1,786 @@
|
||||
import { access, chmod, cp, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { app } from 'electron';
|
||||
import { getProviderAccount, getDefaultProviderAccountId } from '@electron/services/providers/provider-store';
|
||||
import { getProviderSecret, getSecretStore } from '@electron/services/secrets/secret-store';
|
||||
import { getProviderDefaultModel } from '@electron/utils/provider-registry';
|
||||
import type { ProviderAccount, ProviderSecret } from '@electron/shared/providers/types';
|
||||
import {
|
||||
getCcConnectAccountCodexHomeDir,
|
||||
getCcConnectCodexHomeDir,
|
||||
getCcConnectProviderProfilePath,
|
||||
} from './cc-connect-paths';
|
||||
|
||||
export type CodexProviderProfile = {
|
||||
providerId: string | null;
|
||||
vendorId: string | null;
|
||||
label?: string;
|
||||
authMode?: string;
|
||||
model?: string;
|
||||
modelRef?: string;
|
||||
supported: boolean;
|
||||
unsupportedReason?: string;
|
||||
codexArgs: string[];
|
||||
env?: Record<string, string>;
|
||||
envKeys?: string[];
|
||||
launcherEnv?: Record<string, string>;
|
||||
ccConnectProvider?: {
|
||||
name: string;
|
||||
apiKeyEnvKey?: string;
|
||||
baseUrl?: string;
|
||||
model?: string;
|
||||
wireApi?: 'responses';
|
||||
};
|
||||
secretAvailable: boolean;
|
||||
codexHomeDir?: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type OpenAIOAuthTokenSet = {
|
||||
idToken: string;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
accountId: string;
|
||||
};
|
||||
|
||||
type OpenAIOAuthTokenResolution = {
|
||||
tokens: OpenAIOAuthTokenSet;
|
||||
source: 'managed' | 'secret';
|
||||
};
|
||||
|
||||
type OpenAIOAuthTokenResolutionOptions = {
|
||||
preferSecret?: boolean;
|
||||
};
|
||||
|
||||
export type CodexOAuthAuthFileSummary = {
|
||||
path: string;
|
||||
exists: boolean;
|
||||
complete: boolean;
|
||||
accountId?: string;
|
||||
authMode?: string;
|
||||
lastRefresh?: string;
|
||||
updatedAt?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type CodexOAuthProviderSummary = {
|
||||
accountId: string;
|
||||
vendorId: string;
|
||||
authMode?: string;
|
||||
hasOAuthSecret: boolean;
|
||||
subject?: string;
|
||||
email?: string;
|
||||
managedMatchesAccount?: boolean;
|
||||
userMatchesAccount?: boolean;
|
||||
};
|
||||
|
||||
export type CodexOAuthStatus = {
|
||||
success: true;
|
||||
managedCodexHome: string;
|
||||
authPath: string;
|
||||
managed: CodexOAuthAuthFileSummary;
|
||||
user: CodexOAuthAuthFileSummary;
|
||||
provider?: CodexOAuthProviderSummary;
|
||||
};
|
||||
|
||||
function resolveModel(account: ProviderAccount): string | undefined {
|
||||
const model = account.model?.trim();
|
||||
if (model) return model;
|
||||
return getProviderDefaultModel(account.vendorId)?.trim() || undefined;
|
||||
}
|
||||
|
||||
function publicProfile(profile: CodexProviderProfile): CodexProviderProfile {
|
||||
const { env, ...rest } = profile;
|
||||
return {
|
||||
...rest,
|
||||
envKeys: Object.keys(env ?? {}),
|
||||
};
|
||||
}
|
||||
|
||||
function tomlString(value: string): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function tomlInlineStringMap(values: Record<string, string>): string {
|
||||
return `{ ${Object.entries(values).map(([key, value]) => `${tomlString(key)} = ${tomlString(value)}`).join(', ')} }`;
|
||||
}
|
||||
|
||||
function normalizeOpenAIResponsesBaseUrl(baseUrl: string): string {
|
||||
return baseUrl.trim().replace(/\/+$/, '').replace(/\/responses$/i, '');
|
||||
}
|
||||
|
||||
function normalizeModelHubCodexResponsesBaseUrl(baseUrl: string): string | null {
|
||||
const trimmed = baseUrl.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
if (url.hostname !== 'aidp.bytedance.net') return null;
|
||||
if (!url.pathname.startsWith('/api/modelhub/online')) return null;
|
||||
url.pathname = '/api/modelhub/online';
|
||||
url.search = '';
|
||||
url.hash = '';
|
||||
return url.toString().replace(/\/$/, '');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeManagedCodexResponsesConfig(options: {
|
||||
accountId: string;
|
||||
providerKey: string;
|
||||
providerName: string;
|
||||
baseUrl: string;
|
||||
envKey: string;
|
||||
model?: string;
|
||||
envHttpHeaders?: Record<string, string>;
|
||||
modelReasoningEffort?: string;
|
||||
}): Promise<string> {
|
||||
const codexHomeDir = getCcConnectAccountCodexHomeDir(options.accountId);
|
||||
await mkdir(codexHomeDir, { recursive: true });
|
||||
const configPath = join(codexHomeDir, 'config.toml');
|
||||
const tableKey = /^[A-Za-z_][A-Za-z0-9_-]*$/.test(options.providerKey)
|
||||
? options.providerKey
|
||||
: tomlString(options.providerKey);
|
||||
const envHeaderEntries = Object.entries(options.envHttpHeaders ?? {});
|
||||
const lines = [
|
||||
...(options.model ? [`model = ${tomlString(options.model)}`] : []),
|
||||
`model_provider = ${tomlString(options.providerKey)}`,
|
||||
...(options.modelReasoningEffort ? [`model_reasoning_effort = ${tomlString(options.modelReasoningEffort)}`] : []),
|
||||
'',
|
||||
`[model_providers.${tableKey}]`,
|
||||
`name = ${tomlString(options.providerName)}`,
|
||||
`base_url = ${tomlString(options.baseUrl)}`,
|
||||
`env_key = ${tomlString(options.envKey)}`,
|
||||
'wire_api = "responses"',
|
||||
...(envHeaderEntries.length > 0
|
||||
? [`env_http_headers = ${tomlInlineStringMap(options.envHttpHeaders ?? {})}`]
|
||||
: []),
|
||||
'',
|
||||
];
|
||||
await writeFile(configPath, lines.join('\n'), { encoding: 'utf8', mode: 0o600 });
|
||||
await chmod(configPath, 0o600).catch(() => {});
|
||||
return codexHomeDir;
|
||||
}
|
||||
|
||||
function stableModelHubSessionId(account: ProviderAccount): string {
|
||||
return `clawx-cc-connect-${account.id}`;
|
||||
}
|
||||
|
||||
function sanitizedEnvKeyPart(value: string): string {
|
||||
const sanitized = value
|
||||
.trim()
|
||||
.replace(/[^A-Za-z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
.toUpperCase();
|
||||
return sanitized || 'HEADER';
|
||||
}
|
||||
|
||||
function accountScopedEnvKey(accountId: string, purpose: string): string {
|
||||
return `CLAWX_CODEX_${sanitizedEnvKeyPart(accountId)}_${sanitizedEnvKeyPart(purpose)}`;
|
||||
}
|
||||
|
||||
function buildCustomHeaderEnv(account: ProviderAccount, options?: { exclude?: Set<string> }): {
|
||||
env: Record<string, string>;
|
||||
envHttpHeaders: Record<string, string>;
|
||||
} {
|
||||
const entries = Object.entries(account.headers ?? {})
|
||||
.map(([name, value]) => [name.trim(), String(value ?? '').trim()] as const)
|
||||
.filter(([name, value]) => name && value)
|
||||
.filter(([name]) => !options?.exclude?.has(name.toLowerCase()));
|
||||
const env: Record<string, string> = {};
|
||||
const envHttpHeaders: Record<string, string> = {};
|
||||
const used = new Set<string>();
|
||||
for (const [name, value] of entries) {
|
||||
const baseKey = accountScopedEnvKey(account.id, `HEADER_${name}`);
|
||||
let envKey = baseKey;
|
||||
let index = 2;
|
||||
while (used.has(envKey)) {
|
||||
envKey = `${baseKey}_${index}`;
|
||||
index += 1;
|
||||
}
|
||||
used.add(envKey);
|
||||
env[envKey] = value;
|
||||
envHttpHeaders[name] = envKey;
|
||||
}
|
||||
return { env, envHttpHeaders };
|
||||
}
|
||||
|
||||
function extractSessionIdFromExtraHeader(value: string): string | undefined {
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined;
|
||||
const sessionId = (parsed as Record<string, unknown>).session_id;
|
||||
return typeof sessionId === 'string' && sessionId.trim() ? sessionId.trim() : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function buildModelHubEnv(account: ProviderAccount, apiKey: string): {
|
||||
env: Record<string, string>;
|
||||
envHttpHeaders: Record<string, string>;
|
||||
} {
|
||||
const apiKeyEnvKey = accountScopedEnvKey(account.id, 'API_KEY');
|
||||
const extraHeaderEnvKey = accountScopedEnvKey(account.id, 'EXTRA_HEADER');
|
||||
const stickySessionEnvKey = accountScopedEnvKey(account.id, 'STICKY_SESSION_ID');
|
||||
const customHeaders = buildCustomHeaderEnv(account, { exclude: new Set(['api-key', 'extra']) });
|
||||
const existingExtraHeader = account.headers?.extra?.trim();
|
||||
const sessionId = existingExtraHeader
|
||||
? extractSessionIdFromExtraHeader(existingExtraHeader) ?? stableModelHubSessionId(account)
|
||||
: stableModelHubSessionId(account);
|
||||
const extraHeader = existingExtraHeader || JSON.stringify({ session_id: sessionId });
|
||||
return {
|
||||
env: {
|
||||
[apiKeyEnvKey]: apiKey,
|
||||
...customHeaders.env,
|
||||
[extraHeaderEnvKey]: extraHeader,
|
||||
[stickySessionEnvKey]: sessionId,
|
||||
},
|
||||
envHttpHeaders: {
|
||||
...customHeaders.envHttpHeaders,
|
||||
'Api-Key': apiKeyEnvKey,
|
||||
extra: extraHeaderEnvKey,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getUserCodexAuthPath(): string {
|
||||
const e2eOverride = process.env.CLAWX_E2E_USER_CODEX_AUTH_JSON?.trim();
|
||||
if (process.env.CLAWX_E2E === '1' && e2eOverride) {
|
||||
return e2eOverride;
|
||||
}
|
||||
return join(app.getPath('home'), '.codex', 'auth.json');
|
||||
}
|
||||
|
||||
async function ensureAccountCodexHome(accountId: string): Promise<string> {
|
||||
const accountHome = getCcConnectAccountCodexHomeDir(accountId);
|
||||
await mkdir(accountHome, { recursive: true });
|
||||
return accountHome;
|
||||
}
|
||||
|
||||
async function migrateLegacyCodexHomeToAccount(accountId: string): Promise<void> {
|
||||
const accountHome = getCcConnectAccountCodexHomeDir(accountId);
|
||||
const legacyHome = getCcConnectCodexHomeDir();
|
||||
const accountExists = await access(accountHome).then(() => true).catch(() => false);
|
||||
if (accountExists) return;
|
||||
const legacyExists = await access(legacyHome).then(() => true).catch(() => false);
|
||||
if (!legacyExists) return;
|
||||
await mkdir(dirname(accountHome), { recursive: true });
|
||||
try {
|
||||
await rename(legacyHome, accountHome);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'EXDEV') {
|
||||
await cp(legacyHome, accountHome, { recursive: true, force: false, errorOnExist: false });
|
||||
await rm(legacyHome, { recursive: true, force: true });
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeManagedOpenAIOAuthAuthFile(
|
||||
tokens: OpenAIOAuthTokenSet,
|
||||
accountId: string,
|
||||
): Promise<string> {
|
||||
const codexHomeDir = getCcConnectAccountCodexHomeDir(accountId);
|
||||
await mkdir(codexHomeDir, { recursive: true });
|
||||
const authPath = join(codexHomeDir, 'auth.json');
|
||||
|
||||
await writeFile(authPath, JSON.stringify({
|
||||
auth_mode: 'chatgpt',
|
||||
OPENAI_API_KEY: null,
|
||||
tokens: {
|
||||
id_token: tokens.idToken,
|
||||
access_token: tokens.accessToken,
|
||||
refresh_token: tokens.refreshToken,
|
||||
account_id: tokens.accountId,
|
||||
},
|
||||
last_refresh: new Date().toISOString(),
|
||||
}, null, 2), { encoding: 'utf8', mode: 0o600 });
|
||||
await chmod(authPath, 0o600).catch(() => {});
|
||||
return codexHomeDir;
|
||||
}
|
||||
|
||||
async function readCompleteCodexAuthTokens(authPath: string): Promise<OpenAIOAuthTokenSet | undefined> {
|
||||
try {
|
||||
const auth = JSON.parse(await readFile(authPath, 'utf8')) as {
|
||||
tokens?: {
|
||||
id_token?: unknown;
|
||||
access_token?: unknown;
|
||||
refresh_token?: unknown;
|
||||
account_id?: unknown;
|
||||
};
|
||||
};
|
||||
const tokens = auth.tokens;
|
||||
if (
|
||||
!tokens ||
|
||||
typeof tokens.id_token !== 'string' ||
|
||||
typeof tokens.access_token !== 'string' ||
|
||||
typeof tokens.refresh_token !== 'string' ||
|
||||
typeof tokens.account_id !== 'string' ||
|
||||
!tokens.id_token.trim() ||
|
||||
!tokens.access_token.trim() ||
|
||||
!tokens.refresh_token.trim() ||
|
||||
!tokens.account_id.trim()
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
idToken: tokens.id_token.trim(),
|
||||
accessToken: tokens.access_token.trim(),
|
||||
refreshToken: tokens.refresh_token.trim(),
|
||||
accountId: tokens.account_id.trim(),
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function readCodexAuthSummary(authPath: string): Promise<CodexOAuthAuthFileSummary> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await readFile(authPath, 'utf8');
|
||||
} catch {
|
||||
return { path: authPath, exists: false, complete: false };
|
||||
}
|
||||
|
||||
const updatedAt = await stat(authPath)
|
||||
.then((fileStat) => fileStat.mtime.toISOString())
|
||||
.catch(() => undefined);
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as {
|
||||
auth_mode?: unknown;
|
||||
tokens?: { account_id?: unknown };
|
||||
last_refresh?: unknown;
|
||||
};
|
||||
const tokens = await readCompleteCodexAuthTokens(authPath);
|
||||
return {
|
||||
path: authPath,
|
||||
exists: true,
|
||||
complete: Boolean(tokens),
|
||||
accountId: tokens?.accountId ?? (
|
||||
typeof parsed.tokens?.account_id === 'string' && parsed.tokens.account_id.trim()
|
||||
? parsed.tokens.account_id.trim()
|
||||
: undefined
|
||||
),
|
||||
authMode: typeof parsed.auth_mode === 'string' ? parsed.auth_mode : undefined,
|
||||
lastRefresh: typeof parsed.last_refresh === 'string' ? parsed.last_refresh : undefined,
|
||||
updatedAt,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
path: authPath,
|
||||
exists: true,
|
||||
complete: false,
|
||||
updatedAt,
|
||||
error: 'Invalid Codex auth.json',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function codexTokensMatchAccount(
|
||||
tokens: OpenAIOAuthTokenSet,
|
||||
account: ProviderAccount,
|
||||
secret?: Extract<ProviderSecret, { type: 'oauth' }>,
|
||||
): boolean {
|
||||
if (!secret) return true;
|
||||
|
||||
const expectedAccountId = secret.subject?.trim();
|
||||
const userAccountId = tokens.accountId.trim();
|
||||
const accessMatches = tokens.accessToken === secret.accessToken;
|
||||
const refreshMatches = tokens.refreshToken === secret.refreshToken;
|
||||
const accountMatches = Boolean(expectedAccountId && userAccountId && expectedAccountId === userAccountId);
|
||||
const providerIdMatches = Boolean(userAccountId && account.id === userAccountId);
|
||||
|
||||
return accessMatches || refreshMatches || accountMatches || providerIdMatches;
|
||||
}
|
||||
|
||||
async function resolveProviderAccount(accountId?: string): Promise<{
|
||||
account: ProviderAccount | null;
|
||||
secret?: Extract<ProviderSecret, { type: 'oauth' }>;
|
||||
}> {
|
||||
const resolvedAccountId = accountId?.trim() || await getDefaultProviderAccountId();
|
||||
const account = resolvedAccountId ? await getProviderAccount(resolvedAccountId) : null;
|
||||
const secret = account ? await getProviderSecret(account.id) : null;
|
||||
return {
|
||||
account,
|
||||
secret: secret?.type === 'oauth' && secret.accessToken && secret.refreshToken ? secret : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveOpenAIOAuthTokens(
|
||||
account: ProviderAccount,
|
||||
secret?: Extract<ProviderSecret, { type: 'oauth' }>,
|
||||
options?: OpenAIOAuthTokenResolutionOptions,
|
||||
): Promise<OpenAIOAuthTokenResolution | undefined> {
|
||||
const secretIdToken = secret?.idToken?.trim();
|
||||
const secretResolution: OpenAIOAuthTokenResolution | undefined = secret && secretIdToken
|
||||
? {
|
||||
tokens: {
|
||||
idToken: secretIdToken,
|
||||
accessToken: secret.accessToken,
|
||||
refreshToken: secret.refreshToken,
|
||||
accountId: secret.subject?.trim() || account.id,
|
||||
},
|
||||
source: 'secret',
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Browser re-login is authoritative once; normal starts keep Codex-rotated managed tokens.
|
||||
if (options?.preferSecret && secretResolution) {
|
||||
return secretResolution;
|
||||
}
|
||||
|
||||
const managedAuthPath = join(await ensureAccountCodexHome(account.id), 'auth.json');
|
||||
const managedTokens = await readCompleteCodexAuthTokens(managedAuthPath);
|
||||
if (managedTokens && codexTokensMatchAccount(managedTokens, account, secret)) {
|
||||
return { tokens: managedTokens, source: 'managed' };
|
||||
}
|
||||
|
||||
return secretResolution;
|
||||
}
|
||||
|
||||
export async function getCcConnectCodexOAuthStatus(payload?: {
|
||||
accountId?: string;
|
||||
}): Promise<CodexOAuthStatus> {
|
||||
const { account, secret } = await resolveProviderAccount(payload?.accountId);
|
||||
const resolvedAccountId = account?.id ?? payload?.accountId?.trim() ?? 'default';
|
||||
const managedCodexHome = await ensureAccountCodexHome(resolvedAccountId);
|
||||
const authPath = join(managedCodexHome, 'auth.json');
|
||||
const userAuthPath = getUserCodexAuthPath();
|
||||
const [managed, user] = await Promise.all([
|
||||
readCodexAuthSummary(authPath),
|
||||
readCodexAuthSummary(userAuthPath),
|
||||
]);
|
||||
|
||||
const managedTokens = account ? await readCompleteCodexAuthTokens(authPath) : undefined;
|
||||
const userTokens = account ? await readCompleteCodexAuthTokens(userAuthPath) : undefined;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
managedCodexHome,
|
||||
authPath,
|
||||
managed,
|
||||
user,
|
||||
...(account ? {
|
||||
provider: {
|
||||
accountId: account.id,
|
||||
vendorId: account.vendorId,
|
||||
authMode: account.authMode,
|
||||
hasOAuthSecret: Boolean(secret),
|
||||
subject: secret?.subject,
|
||||
email: secret?.email,
|
||||
managedMatchesAccount: managedTokens ? codexTokensMatchAccount(managedTokens, account, secret) : undefined,
|
||||
userMatchesAccount: userTokens ? codexTokensMatchAccount(userTokens, account, secret) : undefined,
|
||||
},
|
||||
} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function importUserCodexOAuthToManagedHome(payload?: {
|
||||
accountId?: string;
|
||||
}): Promise<CodexOAuthStatus> {
|
||||
const { account, secret } = await resolveProviderAccount(payload?.accountId);
|
||||
const userAuthPath = getUserCodexAuthPath();
|
||||
const tokens = await readCompleteCodexAuthTokens(userAuthPath);
|
||||
if (!tokens) {
|
||||
throw new Error(`No complete Codex OAuth auth.json found at ${userAuthPath}`);
|
||||
}
|
||||
if (account && !codexTokensMatchAccount(tokens, account, secret)) {
|
||||
throw new Error('Local Codex OAuth credentials do not match the selected provider account');
|
||||
}
|
||||
await writeManagedOpenAIOAuthAuthFile(tokens, account?.id ?? payload?.accountId?.trim() ?? 'default');
|
||||
return getCcConnectCodexOAuthStatus({ accountId: account?.id ?? payload?.accountId });
|
||||
}
|
||||
|
||||
export async function logoutCcConnectCodexOAuth(payload?: {
|
||||
accountId?: string;
|
||||
managedOnly?: boolean;
|
||||
}): Promise<CodexOAuthStatus> {
|
||||
const { account } = await resolveProviderAccount(payload?.accountId);
|
||||
const accountId = account?.id ?? payload?.accountId?.trim() ?? 'default';
|
||||
const managedHome = await ensureAccountCodexHome(accountId);
|
||||
await rm(join(managedHome, 'auth.json'), { force: true });
|
||||
if (!payload?.managedOnly && account?.authMode === 'oauth_browser') {
|
||||
await getSecretStore().delete(account.id);
|
||||
}
|
||||
return getCcConnectCodexOAuthStatus({ accountId });
|
||||
}
|
||||
|
||||
async function buildProfileForAccount(
|
||||
account: ProviderAccount,
|
||||
options?: OpenAIOAuthTokenResolutionOptions,
|
||||
): Promise<CodexProviderProfile> {
|
||||
const secret = await getProviderSecret(account.id);
|
||||
const model = resolveModel(account);
|
||||
const base = {
|
||||
providerId: account.id,
|
||||
vendorId: account.vendorId,
|
||||
label: account.label,
|
||||
authMode: account.authMode,
|
||||
model,
|
||||
modelRef: model ? `${account.vendorId}/${model}` : undefined,
|
||||
secretAvailable: Boolean(secret),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (account.vendorId === 'openai') {
|
||||
if (account.authMode === 'oauth_browser') {
|
||||
const oauthSecret = secret?.type === 'oauth' && secret.accessToken && secret.refreshToken
|
||||
? secret
|
||||
: undefined;
|
||||
const tokenResolution = await resolveOpenAIOAuthTokens(account, oauthSecret, options);
|
||||
if (!tokenResolution) {
|
||||
return {
|
||||
...base,
|
||||
supported: false,
|
||||
unsupportedReason: 'Codex OAuth credentials are missing. Sign in to Codex using the ClawX-managed CODEX_HOME or sign in to OpenAI again before using cc-connect Codex runtime.',
|
||||
codexArgs: [],
|
||||
};
|
||||
}
|
||||
const codexHomeDir = tokenResolution.source === 'managed'
|
||||
? await ensureAccountCodexHome(account.id)
|
||||
: await writeManagedOpenAIOAuthAuthFile(tokenResolution.tokens, account.id);
|
||||
return {
|
||||
...base,
|
||||
supported: true,
|
||||
codexArgs: model ? ['--model', model] : [],
|
||||
env: { CODEX_HOME: codexHomeDir },
|
||||
codexHomeDir,
|
||||
secretAvailable: true,
|
||||
};
|
||||
}
|
||||
|
||||
const env: Record<string, string> = {};
|
||||
const apiKeyEnvKey = accountScopedEnvKey(account.id, 'API_KEY');
|
||||
if ((secret?.type === 'api_key' || secret?.type === 'local') && secret.apiKey) {
|
||||
env[apiKeyEnvKey] = secret.apiKey;
|
||||
}
|
||||
if (!env[apiKeyEnvKey]) {
|
||||
return {
|
||||
...base,
|
||||
supported: false,
|
||||
unsupportedReason: 'OpenAI API key credentials are missing. Add an OpenAI API key before using the cc-connect Codex runtime with this provider.',
|
||||
codexArgs: [],
|
||||
};
|
||||
}
|
||||
const baseUrl = account.baseUrl?.trim();
|
||||
if (baseUrl) {
|
||||
const providerKey = 'clawx-openai';
|
||||
const normalizedBaseUrl = normalizeOpenAIResponsesBaseUrl(baseUrl);
|
||||
const codexHomeDir = await writeManagedCodexResponsesConfig({
|
||||
accountId: account.id,
|
||||
providerKey,
|
||||
providerName: 'OpenAI',
|
||||
baseUrl: normalizedBaseUrl,
|
||||
envKey: apiKeyEnvKey,
|
||||
model,
|
||||
});
|
||||
return {
|
||||
...base,
|
||||
supported: true,
|
||||
codexArgs: [
|
||||
'-c',
|
||||
`model_provider=${tomlString(providerKey)}`,
|
||||
'-c',
|
||||
`model_providers.${providerKey}.name="OpenAI"`,
|
||||
'-c',
|
||||
`model_providers.${providerKey}.base_url=${tomlString(normalizedBaseUrl)}`,
|
||||
'-c',
|
||||
`model_providers.${providerKey}.env_key=${tomlString(apiKeyEnvKey)}`,
|
||||
'-c',
|
||||
`model_providers.${providerKey}.wire_api="responses"`,
|
||||
...(model ? ['--model', model] : []),
|
||||
],
|
||||
env: {
|
||||
...env,
|
||||
CODEX_HOME: codexHomeDir,
|
||||
},
|
||||
codexHomeDir,
|
||||
launcherEnv: { OPENAI_API_KEY: apiKeyEnvKey },
|
||||
ccConnectProvider: {
|
||||
name: providerKey,
|
||||
apiKeyEnvKey,
|
||||
baseUrl: normalizedBaseUrl,
|
||||
wireApi: 'responses',
|
||||
...(model ? { model } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
const codexHomeDir = await ensureAccountCodexHome(account.id);
|
||||
return {
|
||||
...base,
|
||||
supported: true,
|
||||
codexArgs: model ? ['--model', model] : [],
|
||||
env: { ...env, CODEX_HOME: codexHomeDir },
|
||||
codexHomeDir,
|
||||
launcherEnv: { OPENAI_API_KEY: apiKeyEnvKey },
|
||||
ccConnectProvider: {
|
||||
name: 'openai',
|
||||
apiKeyEnvKey,
|
||||
...(model ? { model } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (account.vendorId === 'custom') {
|
||||
const protocol = account.apiProtocol || 'openai-completions';
|
||||
if (protocol !== 'openai-responses') {
|
||||
return {
|
||||
...base,
|
||||
supported: false,
|
||||
unsupportedReason: `cc-connect Codex runtime cannot use custom provider "${account.label}" because Codex 0.137 only supports the Responses wire API. This provider is configured for Chat Completions.`,
|
||||
codexArgs: [],
|
||||
};
|
||||
}
|
||||
|
||||
const baseUrl = account.baseUrl?.trim();
|
||||
if (!baseUrl) {
|
||||
return {
|
||||
...base,
|
||||
supported: false,
|
||||
unsupportedReason: `cc-connect Codex runtime cannot use custom provider "${account.label}" because a Responses-compatible base URL is required.`,
|
||||
codexArgs: [],
|
||||
};
|
||||
}
|
||||
|
||||
if ((secret?.type !== 'api_key' && secret?.type !== 'local') || !secret.apiKey) {
|
||||
return {
|
||||
...base,
|
||||
supported: false,
|
||||
unsupportedReason: `cc-connect Codex runtime cannot use custom provider "${account.label}" because its API key is missing.`,
|
||||
codexArgs: [],
|
||||
};
|
||||
}
|
||||
|
||||
const modelHubBaseUrl = normalizeModelHubCodexResponsesBaseUrl(baseUrl);
|
||||
const providerKey = modelHubBaseUrl ? 'modelhub_openapi' : 'clawx-custom';
|
||||
const envKey = accountScopedEnvKey(account.id, 'API_KEY');
|
||||
const normalizedBaseUrl = modelHubBaseUrl ?? normalizeOpenAIResponsesBaseUrl(baseUrl);
|
||||
const customHeaders = modelHubBaseUrl
|
||||
? buildModelHubEnv(account, secret.apiKey)
|
||||
: buildCustomHeaderEnv(account);
|
||||
const env: Record<string, string> = modelHubBaseUrl
|
||||
? customHeaders.env
|
||||
: { [envKey]: secret.apiKey, ...customHeaders.env };
|
||||
const envHttpHeaders = Object.keys(customHeaders.envHttpHeaders).length > 0
|
||||
? customHeaders.envHttpHeaders
|
||||
: undefined;
|
||||
const codexHomeDir = await writeManagedCodexResponsesConfig({
|
||||
accountId: account.id,
|
||||
providerKey,
|
||||
providerName: modelHubBaseUrl ? 'ByteDance ModelHub OpenAPI' : (account.label || 'Custom'),
|
||||
baseUrl: normalizedBaseUrl,
|
||||
envKey,
|
||||
model,
|
||||
envHttpHeaders,
|
||||
...(modelHubBaseUrl ? { modelReasoningEffort: 'none' } : {}),
|
||||
});
|
||||
return {
|
||||
...base,
|
||||
supported: true,
|
||||
codexArgs: [
|
||||
'-c',
|
||||
`model_provider=${tomlString(providerKey)}`,
|
||||
'-c',
|
||||
`model_providers.${providerKey}.name=${tomlString(modelHubBaseUrl ? 'ByteDance ModelHub OpenAPI' : (account.label || 'Custom'))}`,
|
||||
'-c',
|
||||
`model_providers.${providerKey}.base_url=${tomlString(normalizedBaseUrl)}`,
|
||||
'-c',
|
||||
`model_providers.${providerKey}.env_key=${tomlString(envKey)}`,
|
||||
'-c',
|
||||
`model_providers.${providerKey}.wire_api="responses"`,
|
||||
...(modelHubBaseUrl ? [
|
||||
'-c',
|
||||
'model_reasoning_effort="none"',
|
||||
] : []),
|
||||
...(envHttpHeaders ? [
|
||||
'-c',
|
||||
`model_providers.${providerKey}.env_http_headers=${tomlInlineStringMap(envHttpHeaders)}`,
|
||||
] : []),
|
||||
...(model ? ['--model', model] : []),
|
||||
],
|
||||
env: {
|
||||
...env,
|
||||
CODEX_HOME: codexHomeDir,
|
||||
},
|
||||
codexHomeDir,
|
||||
ccConnectProvider: {
|
||||
name: providerKey,
|
||||
apiKeyEnvKey: envKey,
|
||||
baseUrl: normalizedBaseUrl,
|
||||
wireApi: 'responses',
|
||||
...(model ? { model } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (account.vendorId === 'ollama') {
|
||||
return {
|
||||
...base,
|
||||
supported: true,
|
||||
codexArgs: [
|
||||
'--oss',
|
||||
'--local-provider',
|
||||
'ollama',
|
||||
...(model ? ['--model', model] : []),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
supported: false,
|
||||
unsupportedReason: `cc-connect Codex runtime currently supports OpenAI/Codex and Ollama provider accounts; "${account.vendorId}" is not supported yet.`,
|
||||
codexArgs: [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildCcConnectProviderProfileForAccount(
|
||||
accountId: string,
|
||||
): Promise<CodexProviderProfile> {
|
||||
const account = await getProviderAccount(accountId);
|
||||
if (account) return buildProfileForAccount(account);
|
||||
return {
|
||||
providerId: accountId,
|
||||
vendorId: null,
|
||||
supported: false,
|
||||
unsupportedReason: `Provider account "${accountId}" was not found`,
|
||||
codexArgs: [],
|
||||
secretAvailable: false,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function syncCcConnectProviderProfile(
|
||||
payload?: { providerId?: string; reason?: string },
|
||||
): Promise<CodexProviderProfile> {
|
||||
const providerId = payload?.providerId?.trim() || await getDefaultProviderAccountId();
|
||||
const account = providerId ? await getProviderAccount(providerId) : null;
|
||||
if (account?.vendorId === 'openai' && account.authMode === 'oauth_browser') {
|
||||
await migrateLegacyCodexHomeToAccount(account.id);
|
||||
}
|
||||
const profile: CodexProviderProfile = account
|
||||
? await buildProfileForAccount(account, { preferSecret: payload?.reason === 'oauth' })
|
||||
: {
|
||||
providerId: null,
|
||||
vendorId: null,
|
||||
supported: true,
|
||||
codexArgs: [],
|
||||
secretAvailable: false,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const profilePath = getCcConnectProviderProfilePath();
|
||||
await mkdir(dirname(profilePath), { recursive: true });
|
||||
await writeFile(profilePath, JSON.stringify({
|
||||
...publicProfile(profile),
|
||||
reason: payload?.reason ?? 'sync',
|
||||
}, null, 2), 'utf8');
|
||||
return profile;
|
||||
}
|
||||
|
||||
export function toPublicCodexProviderProfile(profile: CodexProviderProfile): CodexProviderProfile {
|
||||
return publicProfile(profile);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,125 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { app } from 'electron';
|
||||
import { getClawXDataLayout, resolveClawXDataRoot } from '../utils/clawx-data-layout';
|
||||
import { getCcConnectManagedDir } from './cc-connect-paths';
|
||||
|
||||
type SessionMetadataDocument = {
|
||||
schema: 'clawx-cc-connect-session-metadata';
|
||||
version: 1;
|
||||
labels: Record<string, string>;
|
||||
updatedAt: string;
|
||||
migratedFromLegacyAt?: string;
|
||||
};
|
||||
|
||||
export interface CcConnectSessionMetadataStore {
|
||||
getLabel(sessionKey: string): Promise<string | undefined>;
|
||||
setLabel(sessionKey: string, label: string): Promise<void>;
|
||||
deleteLabel(sessionKey: string): Promise<void>;
|
||||
}
|
||||
|
||||
function defaultMetadataPath(): string {
|
||||
const layout = getClawXDataLayout(resolveClawXDataRoot(process.env, app.getPath('userData')));
|
||||
return join(layout.appDir, 'cc-connect-session-metadata.json');
|
||||
}
|
||||
|
||||
function defaultLegacyPath(): string {
|
||||
return join(getCcConnectManagedDir(), 'data', 'sessions', '.clawx-supplemental-history.json');
|
||||
}
|
||||
|
||||
async function writeAtomic(path: string, document: SessionMetadataDocument): Promise<void> {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
||||
await writeFile(temporaryPath, `${JSON.stringify(document, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
||||
await chmod(temporaryPath, 0o600).catch(() => {});
|
||||
await rename(temporaryPath, path);
|
||||
await chmod(path, 0o600).catch(() => {});
|
||||
}
|
||||
|
||||
function emptyDocument(): SessionMetadataDocument {
|
||||
return {
|
||||
schema: 'clawx-cc-connect-session-metadata',
|
||||
version: 1,
|
||||
labels: {},
|
||||
updatedAt: new Date(0).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizedLabels(value: unknown): Record<string, string> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
||||
return Object.fromEntries(Object.entries(value).flatMap(([key, label]) => (
|
||||
typeof label === 'string' && label.trim() ? [[key, label.trim().slice(0, 80)]] : []
|
||||
)));
|
||||
}
|
||||
|
||||
export class FileCcConnectSessionMetadataStore implements CcConnectSessionMetadataStore {
|
||||
private queue = Promise.resolve();
|
||||
|
||||
constructor(
|
||||
private readonly metadataPath = defaultMetadataPath(),
|
||||
private readonly legacyPath = defaultLegacyPath(),
|
||||
) {}
|
||||
|
||||
async getLabel(sessionKey: string): Promise<string | undefined> {
|
||||
const document = await this.readDocument();
|
||||
return document.labels[sessionKey];
|
||||
}
|
||||
|
||||
async setLabel(sessionKey: string, label: string): Promise<void> {
|
||||
const normalized = label.trim().slice(0, 80);
|
||||
if (!normalized) throw new Error('Label cannot be empty');
|
||||
await this.exclusive(async () => {
|
||||
const document = await this.readDocument();
|
||||
document.labels[sessionKey] = normalized;
|
||||
document.updatedAt = new Date().toISOString();
|
||||
await writeAtomic(this.metadataPath, document);
|
||||
});
|
||||
}
|
||||
|
||||
async deleteLabel(sessionKey: string): Promise<void> {
|
||||
await this.exclusive(async () => {
|
||||
const document = await this.readDocument();
|
||||
if (!(sessionKey in document.labels)) return;
|
||||
delete document.labels[sessionKey];
|
||||
document.updatedAt = new Date().toISOString();
|
||||
await writeAtomic(this.metadataPath, document);
|
||||
});
|
||||
}
|
||||
|
||||
private async readDocument(): Promise<SessionMetadataDocument> {
|
||||
try {
|
||||
const parsed = JSON.parse(await readFile(this.metadataPath, 'utf8')) as Partial<SessionMetadataDocument>;
|
||||
if (parsed.schema !== 'clawx-cc-connect-session-metadata' || parsed.version !== 1) {
|
||||
throw new Error(`Unsupported cc-connect session metadata: ${this.metadataPath}`);
|
||||
}
|
||||
return { ...parsed, labels: normalizedLabels(parsed.labels) } as SessionMetadataDocument;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||
}
|
||||
|
||||
const document = emptyDocument();
|
||||
try {
|
||||
const legacy = JSON.parse(await readFile(this.legacyPath, 'utf8')) as { labels?: unknown };
|
||||
document.labels = normalizedLabels(legacy.labels);
|
||||
document.migratedFromLegacyAt = new Date().toISOString();
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||
}
|
||||
document.updatedAt = new Date().toISOString();
|
||||
await writeAtomic(this.metadataPath, document);
|
||||
return document;
|
||||
}
|
||||
|
||||
private async exclusive<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const previous = this.queue;
|
||||
let release!: () => void;
|
||||
this.queue = new Promise<void>((resolve) => { release = resolve; });
|
||||
await previous;
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { cp, mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { basename, join } from 'node:path';
|
||||
import type { SkillsStatusResult } from '@shared/host-api/contract';
|
||||
import { getCcConnectCodexHomeDir } from './cc-connect-paths';
|
||||
import { listLocalSkills, type LocalSkillRecord } from '../services/skills/local-skill-service';
|
||||
|
||||
function safeSkillDirName(skill: Pick<LocalSkillRecord, 'id' | 'slug' | 'baseDir'>): string {
|
||||
const candidate = skill.slug || skill.id || (skill.baseDir ? basename(skill.baseDir) : 'skill');
|
||||
return candidate.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'skill';
|
||||
}
|
||||
|
||||
function isCodexNativeSkill(skill: LocalSkillRecord): boolean {
|
||||
return skill.source === 'agents-skills-personal' || skill.source === 'agents-skills-project';
|
||||
}
|
||||
|
||||
export async function syncCcConnectSkillRecords(
|
||||
records: LocalSkillRecord[],
|
||||
codexHomeDir = getCcConnectCodexHomeDir(),
|
||||
): Promise<SkillsStatusResult> {
|
||||
const skillsRoot = join(codexHomeDir, 'skills');
|
||||
await mkdir(skillsRoot, { recursive: true });
|
||||
const enabled = records.filter((skill) => skill.enabled !== false && skill.baseDir);
|
||||
const manifest: Array<Record<string, unknown>> = [];
|
||||
|
||||
for (const skill of enabled) {
|
||||
const targetDirName = safeSkillDirName(skill);
|
||||
const targetDir = join(skillsRoot, targetDirName);
|
||||
await rm(targetDir, { recursive: true, force: true });
|
||||
const native = isCodexNativeSkill(skill);
|
||||
if (!native) {
|
||||
await cp(skill.baseDir!, targetDir, { recursive: true, force: true });
|
||||
}
|
||||
const runtimeDir = native ? skill.baseDir! : targetDir;
|
||||
manifest.push({
|
||||
skillKey: skill.id,
|
||||
slug: skill.slug,
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
source: skill.source,
|
||||
baseDir: runtimeDir,
|
||||
filePath: join(runtimeDir, 'SKILL.md'),
|
||||
projection: native ? 'codex-native' : 'mirrored',
|
||||
version: skill.version,
|
||||
bundled: skill.isBundled,
|
||||
always: skill.isCore,
|
||||
});
|
||||
}
|
||||
|
||||
await writeFile(join(skillsRoot, 'manifest.json'), JSON.stringify({
|
||||
updatedAt: new Date().toISOString(),
|
||||
skills: manifest,
|
||||
}, null, 2), 'utf8');
|
||||
|
||||
return {
|
||||
skills: manifest.map((skill) => ({
|
||||
skillKey: String(skill.skillKey || ''),
|
||||
slug: typeof skill.slug === 'string' ? skill.slug : undefined,
|
||||
name: typeof skill.name === 'string' ? skill.name : undefined,
|
||||
description: typeof skill.description === 'string' ? skill.description : undefined,
|
||||
disabled: false,
|
||||
version: typeof skill.version === 'string' ? skill.version : undefined,
|
||||
bundled: skill.bundled === true,
|
||||
always: skill.always === true,
|
||||
source: typeof skill.source === 'string' ? skill.source : undefined,
|
||||
baseDir: typeof skill.baseDir === 'string' ? skill.baseDir : undefined,
|
||||
filePath: typeof skill.filePath === 'string' ? skill.filePath : undefined,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function syncCcConnectSkills(codexHomeDir?: string): Promise<SkillsStatusResult> {
|
||||
return syncCcConnectSkillRecords(await listLocalSkills(), codexHomeDir);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { app } from 'electron';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
export type CodexBundle = {
|
||||
baseDir: string;
|
||||
binaryPath: string;
|
||||
pathDir: string;
|
||||
targetTriple: string;
|
||||
};
|
||||
|
||||
function codexBinaryName(): string {
|
||||
return process.platform === 'win32' ? 'codex.exe' : 'codex';
|
||||
}
|
||||
|
||||
function codexTargetTriple(platform = process.platform, arch = process.arch): string {
|
||||
if (platform === 'darwin' && arch === 'x64') return 'x86_64-apple-darwin';
|
||||
if (platform === 'darwin' && arch === 'arm64') return 'aarch64-apple-darwin';
|
||||
if (platform === 'linux' && arch === 'x64') return 'x86_64-unknown-linux-musl';
|
||||
if (platform === 'linux' && arch === 'arm64') return 'aarch64-unknown-linux-musl';
|
||||
if (platform === 'win32' && arch === 'x64') return 'x86_64-pc-windows-msvc';
|
||||
if (platform === 'win32' && arch === 'arm64') return 'aarch64-pc-windows-msvc';
|
||||
throw new Error(`Unsupported Codex target: ${platform}-${arch}`);
|
||||
}
|
||||
|
||||
function baseDir(): string {
|
||||
if (app.isPackaged) {
|
||||
return join(process.resourcesPath, 'codex');
|
||||
}
|
||||
if (process.env.CLAWX_CODEX_PATH) {
|
||||
return dirname(dirname(process.env.CLAWX_CODEX_PATH));
|
||||
}
|
||||
return join(process.cwd(), 'build', 'codex', `${process.platform}-${process.arch}`);
|
||||
}
|
||||
|
||||
export function getCodexBundle(): CodexBundle {
|
||||
const base = baseDir();
|
||||
return {
|
||||
baseDir: base,
|
||||
binaryPath: join(base, 'bin', codexBinaryName()),
|
||||
pathDir: join(base, 'codex-path'),
|
||||
targetTriple: codexTargetTriple(),
|
||||
};
|
||||
}
|
||||
|
||||
export function assertCodexBundle(candidate = getCodexBundle()): CodexBundle {
|
||||
if (!existsSync(candidate.binaryPath)) {
|
||||
throw new Error(
|
||||
`Codex binary not found at ${candidate.binaryPath}. Run pnpm run bundle:codex:current before selecting cc-connect runtime.`,
|
||||
);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
export function prependCodexPathDir(env: NodeJS.ProcessEnv, bundle = getCodexBundle()): NodeJS.ProcessEnv {
|
||||
if (!existsSync(bundle.pathDir)) return env;
|
||||
const delimiter = process.platform === 'win32' ? ';' : ':';
|
||||
return {
|
||||
...env,
|
||||
PATH: [bundle.pathDir, env.PATH || ''].filter(Boolean).join(delimiter),
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user