wtagent 0.1.0-alpha.1 → 0.1.0-alpha.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -6
- package/docs/technical-design.md +30 -4
- package/package.json +4 -2
- package/src/browser/cdp-browser.js +70 -2
- package/src/browser/cdp-state.js +228 -22
- package/src/browser/chatgpt-web-adapter.js +207 -33
- package/src/browser/fake-web-model-adapter.js +3 -1
- package/src/browser/mode-selection.js +18 -5
- package/src/cli/at-files.js +3 -1
- package/src/cli/main.js +19 -9
- package/src/cli/render-events.js +13 -1
- package/src/platform/chrome-discovery.js +7 -6
- package/src/platform/command-launcher.js +266 -0
- package/src/platform/paths.js +16 -12
- package/src/platform/windows-diagnostics.js +436 -0
- package/src/policy/path-guard.js +104 -38
- package/src/protocol/xml-protocol.js +86 -10
- package/src/runtime/agent-runtime.js +93 -19
- package/src/session/agent-session.js +6 -9
- package/src/shared/atomic-write.js +40 -0
- package/src/shared/limits.js +4 -3
- package/src/shared/text-budget.js +112 -0
- package/src/tools/default-tools.js +119 -65
- package/src/tools/process-manager.js +97 -23
- package/src/tools/process-utils.js +6 -3
- package/src/tools/search-fallback.js +191 -0
- package/src/tools/terminal-exec.js +157 -30
package/README.md
CHANGED
|
@@ -13,16 +13,37 @@ WTAgent 把 GPT 网页聊天连接到本地工具,提供类似 Codex 的 CLI A
|
|
|
13
13
|
网页 GPT 负责思考,WTAgent 在本机:
|
|
14
14
|
|
|
15
15
|
- 读取和修改本地文件
|
|
16
|
-
-
|
|
16
|
+
- 调用结构化本地命令,运行构建、测试和开发服务
|
|
17
17
|
|
|
18
18
|
不需要 OpenAI API Key,也不要求 ChatGPT Pro。WTAgent 使用独立的 Chrome Profile 保存你的网页登录状态,并使用该账号在网页上实际可用的模型和额度;如果账号拥有 Pro,它会成为额外优势。
|
|
19
19
|
|
|
20
|
+
支持矩阵:
|
|
21
|
+
|
|
22
|
+
- macOS
|
|
23
|
+
- Linux
|
|
24
|
+
- Windows 10 1809+ / Windows 11 x64
|
|
25
|
+
- Windows ARM64 目前为预览支持
|
|
26
|
+
|
|
27
|
+
边界:
|
|
28
|
+
|
|
29
|
+
- 需要 Chrome 或 Chromium;首版不支持 Edge
|
|
30
|
+
- Git 和 `rg` 是可选项,不是运行前提
|
|
31
|
+
- WSL 不在首版支持范围内;请直接在原生 PowerShell / CMD / Windows Terminal 中运行
|
|
32
|
+
|
|
20
33
|
### 快速开始
|
|
21
34
|
|
|
22
35
|
需要 Node.js 20.17+ 和 Chrome/Chromium。
|
|
23
36
|
|
|
24
37
|
```bash
|
|
25
|
-
npm install -g wtagent@alpha
|
|
38
|
+
npm install -g wtagent@alpha --registry=https://registry.npmjs.org/
|
|
39
|
+
wtagent login
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Windows PowerShell:
|
|
43
|
+
|
|
44
|
+
```powershell
|
|
45
|
+
npm install -g wtagent@alpha --registry=https://registry.npmjs.org/
|
|
46
|
+
wtagent doctor
|
|
26
47
|
wtagent login
|
|
27
48
|
```
|
|
28
49
|
|
|
@@ -40,10 +61,16 @@ wtagent "检查这个项目并修复测试"
|
|
|
40
61
|
wtagent -C ./my-project "创建一个网站"
|
|
41
62
|
```
|
|
42
63
|
|
|
43
|
-
之后直接在终端中继续对话。WTAgent 会把任务交给 GPT Web
|
|
64
|
+
之后直接在终端中继续对话。WTAgent 会把任务交给 GPT Web,并在本地执行文件操作和结构化本地命令。使用 `↑` / `↓` 浏览本次 CLI 会话的历史输入。
|
|
44
65
|
|
|
45
66
|
按 `Ctrl+C` 或 `Ctrl+D` 可退出并关闭专用 Chrome。若上一次异常退出留下了 Chrome,WTAgent 会在验证其 CDP 身份后复用并接管它。
|
|
46
67
|
|
|
68
|
+
常见说明:
|
|
69
|
+
|
|
70
|
+
- `wtagent doctor` 会检查 Node、Chrome、目录权限和运行环境
|
|
71
|
+
- 首次登录只会写入 WTAgent 的专用 Chrome Profile,不会接管你的日常浏览器 Profile
|
|
72
|
+
- 如果系统没有 Git 或 `rg`,WTAgent 仍应保留核心文件读写与搜索能力
|
|
73
|
+
|
|
47
74
|
## English
|
|
48
75
|
|
|
49
76
|
WTAgent connects GPT Web chat to local tools, providing a Codex-like CLI agent experience. The first adapter currently supports ChatGPT Web.
|
|
@@ -51,16 +78,37 @@ WTAgent connects GPT Web chat to local tools, providing a Codex-like CLI agent e
|
|
|
51
78
|
GPT Web handles reasoning. WTAgent runs locally to:
|
|
52
79
|
|
|
53
80
|
- Read and edit local files
|
|
54
|
-
- Run
|
|
81
|
+
- Run structured local commands, builds, tests, and development servers
|
|
55
82
|
|
|
56
83
|
No OpenAI API key or ChatGPT Pro subscription is required. WTAgent stores your web login in a dedicated Chrome profile and uses the models and quota actually available to that account. Pro is an optional bonus when the account has it.
|
|
57
84
|
|
|
85
|
+
Support matrix:
|
|
86
|
+
|
|
87
|
+
- macOS
|
|
88
|
+
- Linux
|
|
89
|
+
- Windows 10 1809+ / Windows 11 x64
|
|
90
|
+
- Windows ARM64 is currently preview only
|
|
91
|
+
|
|
92
|
+
Boundaries:
|
|
93
|
+
|
|
94
|
+
- Requires Chrome or Chromium; Edge is out of scope for the first Windows release
|
|
95
|
+
- Git and `rg` are optional accelerators, not runtime requirements
|
|
96
|
+
- WSL is not supported in v1; run WTAgent from native PowerShell, CMD, or Windows Terminal
|
|
97
|
+
|
|
58
98
|
### Quick start
|
|
59
99
|
|
|
60
100
|
Requires Node.js 20.17+ and Chrome/Chromium.
|
|
61
101
|
|
|
62
102
|
```bash
|
|
63
|
-
npm install -g wtagent@alpha
|
|
103
|
+
npm install -g wtagent@alpha --registry=https://registry.npmjs.org/
|
|
104
|
+
wtagent login
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Windows PowerShell:
|
|
108
|
+
|
|
109
|
+
```powershell
|
|
110
|
+
npm install -g wtagent@alpha --registry=https://registry.npmjs.org/
|
|
111
|
+
wtagent doctor
|
|
64
112
|
wtagent login
|
|
65
113
|
```
|
|
66
114
|
|
|
@@ -78,10 +126,16 @@ wtagent "inspect this project and fix the tests"
|
|
|
78
126
|
wtagent -C ./my-project "build a website"
|
|
79
127
|
```
|
|
80
128
|
|
|
81
|
-
Continue chatting in the terminal. WTAgent sends tasks to GPT Web and executes file and
|
|
129
|
+
Continue chatting in the terminal. WTAgent sends tasks to GPT Web and executes file operations and structured local commands. Use `↑` / `↓` to browse input history from the current CLI session.
|
|
82
130
|
|
|
83
131
|
Press `Ctrl+C` or `Ctrl+D` to exit and close the dedicated Chrome. If an abnormal exit leaves Chrome running, WTAgent verifies and adopts that CDP instance on the next start.
|
|
84
132
|
|
|
133
|
+
Notes:
|
|
134
|
+
|
|
135
|
+
- `wtagent doctor` checks Node, Chrome, writable data paths, and runtime support
|
|
136
|
+
- WTAgent always uses its own Chrome profile and does not reuse your daily browsing profile
|
|
137
|
+
- Core file and search capabilities must continue to work even when Git or `rg` is missing
|
|
138
|
+
|
|
85
139
|
## License
|
|
86
140
|
|
|
87
141
|
[MIT](./LICENSE)
|
package/docs/technical-design.md
CHANGED
|
@@ -11,11 +11,20 @@
|
|
|
11
11
|
- 双方通过普通聊天文本中的自定义 XML 交换工具调用和结果。
|
|
12
12
|
- Runtime 循环执行“网页回复 → 解析工具 → 本地执行 → 回填结果”,直到任务完成。
|
|
13
13
|
|
|
14
|
+
原生 Windows 支持约束:
|
|
15
|
+
|
|
16
|
+
- 发布目标是 Windows 10 1809+ / Windows 11 x64;ARM64 先作为预览
|
|
17
|
+
- 支持从 PowerShell、CMD 或 Windows Terminal 启动
|
|
18
|
+
- 仅支持 Chrome / Chromium,不把 Edge 计入首版兼容矩阵
|
|
19
|
+
- Git、`rg`、Codex、Claude Code 都不是运行依赖
|
|
20
|
+
- WSL 不在首版范围内,需要从原生 Windows 终端直接运行
|
|
21
|
+
- Windows 上仍保持同一个结构化执行协议:`program + argv + cwd`
|
|
22
|
+
|
|
14
23
|
建议的首版技术组合:
|
|
15
24
|
|
|
16
25
|
| 领域 | 选择 |
|
|
17
26
|
| --- | --- |
|
|
18
|
-
| 运行时 | Node.js
|
|
27
|
+
| 运行时 | Node.js 20.17+,JavaScript ESM |
|
|
19
28
|
| CLI | `commander` + `@inquirer/prompts`,终端渲染可选 `ink` |
|
|
20
29
|
| 浏览器控制 | `playwright-core`,使用用户已安装的 Chrome,非 headless |
|
|
21
30
|
| XML | `saxes` 或 `fast-xml-parser`;工具参数按注册 Schema 二次校验 |
|
|
@@ -50,6 +59,12 @@
|
|
|
50
59
|
- V1 支持 macOS、Windows、Linux。
|
|
51
60
|
- V1 是 CLI,不做桌面 GUI 和远程控制台。
|
|
52
61
|
|
|
62
|
+
其中 Windows 的产品边界需要额外强调:
|
|
63
|
+
|
|
64
|
+
- `.cmd` / `.bat` 兼容属于运行时适配层职责,不能暴露为新的 shell-string 工具
|
|
65
|
+
- `wtagent doctor` 需要明确区分“必需失败”“可选缺失”“能力降级”和“不支持的 WSL”
|
|
66
|
+
- 打包发布必须经过原生 Windows 全局安装 smoke、路径带空格/中文、以及 Chrome 专用 Profile 验证
|
|
67
|
+
|
|
53
68
|
### 2.3 一个必须正视的技术事实
|
|
54
69
|
|
|
55
70
|
普通 ChatGPT 网页聊天没有真正的 system message、tool schema 或 tool result channel。所谓“system prompt”“工具调用”“工具结果”都是由 CLI 作为普通用户文本发送。因此:
|
|
@@ -519,11 +534,18 @@ V1 不需要把 `cd` 暴露给模型;每个命令都有显式 `cwd`,且必
|
|
|
519
534
|
|
|
520
535
|
- `cwd` 解析后必须位于项目根目录。
|
|
521
536
|
- stdout/stderr 分开捕获并流式展示。
|
|
522
|
-
-
|
|
537
|
+
- stdout 与 stderr 回填给网页模型的合计上限为 4 KiB;达到上限后按 UTF-8 字节安全地保留约 1 KiB 头部和 3 KiB 尾部,并标记原始与省略字节数。
|
|
538
|
+
- 工具说明要求模型优先使用 `fs.search`、分页 `fs.read`、窄路径、子命令和测试过滤参数缩小输出;不假设系统已安装 Git、`rg` 或 Unix 文本工具。`terminal.exec` 不提供管道、重定向或其他 Shell 运算符。
|
|
539
|
+
- 单次调用流式写入本地 `tool-output.jsonl` 的原始命令日志最多保留 4 MiB,超过后停止记录剩余日志,但不因此终止命令。
|
|
523
540
|
- 超时先优雅终止,再强制终止进程树。
|
|
524
541
|
- Windows 必须处理子进程树终止。
|
|
525
542
|
- 返回 exit code、signal、duration 和截断信息。
|
|
526
543
|
|
|
544
|
+
文件读取与网页传输另有两层硬限制:
|
|
545
|
+
|
|
546
|
+
- `fs.read` 单次最多读取 16 KiB,通过返回的 `nextOffset` 继续读取;分段边界不得切断 UTF-8 字符。
|
|
547
|
+
- 最终发送到浏览器的单条工具结果,包括 XML、续跑信息和 `<system_reminder>`,不得超过 24 KiB。Runtime 在字段级截断后生成完整 XML,Browser Adapter 在写入 composer 前再次按 UTF-8 字节数校验;禁止直接截断已经序列化的 XML。
|
|
548
|
+
|
|
527
549
|
### 7.4 长运行进程
|
|
528
550
|
|
|
529
551
|
网站场景离不开 dev server,不能让 `terminal.exec` 永久阻塞。
|
|
@@ -593,7 +615,7 @@ sessions/<session-id>/
|
|
|
593
615
|
tool-output.jsonl
|
|
594
616
|
```
|
|
595
617
|
|
|
596
|
-
`session.json` 保存项目根目录、ChatGPT 会话 URL
|
|
618
|
+
`session.json` 保存项目根目录、ChatGPT 会话 URL、最近确认的 assistant message ID、最近确认的实际模式、当前 run phase、最近 turn、等待回填的工具结果和副作用恢复日志。它没有不可继续的 `completed task` 状态;`done=true` 只结束当前 run,Session 回到 `idle`。
|
|
597
619
|
|
|
598
620
|
`rollout-*.jsonl` 从创建时起直接使用 **Codex rollout 风格**:首行 `session_meta`,后续每行 `{timestamp, type: "response_item", payload}`,payload 采用 OpenAI Responses 形状(`message` / `function_call` / `function_call_output`)。
|
|
599
621
|
|
|
@@ -628,6 +650,8 @@ sessionId + assistantMessageIdentity + normalizedToolCall
|
|
|
628
650
|
|
|
629
651
|
- Chrome 仍在:根据专用 Profile、PID、CDP 端口和健康检查验证身份,复用浏览器并创建新的 Page。
|
|
630
652
|
- Chrome 崩溃:用相同 Profile 重启并打开会话 URL。
|
|
653
|
+
- 同一 CLI 进程中的 follow-up 保持在当前会话 Page,不重复导航;跨进程恢复需要等待本地记录的最近 assistant message ID 出现在 DOM 后才能发送。
|
|
654
|
+
- 每次发送记录已有消息 ID 和新 user turn,只接受位于该 user turn 之后的新 assistant turn;无法建立可靠消息身份时超时并保存诊断,禁止退化为基于数量或文本变化猜测。
|
|
631
655
|
- 同一 Profile 同时只允许一个 WTAgent CLI Session;启动和接管过程使用 Profile 级互斥锁。
|
|
632
656
|
- 登录失效或出现验证:进入 `AUTH_REQUIRED`/`PAUSED`,让用户接管。
|
|
633
657
|
- 会话页面丢失:从本地记录打开原会话;无法恢复时创建新的本地 Session 和新的网页对话,不向旧 rollout 继续追加。
|
|
@@ -640,6 +664,8 @@ sessionId + assistantMessageIdentity + normalizedToolCall
|
|
|
640
664
|
2. 当前可用工具及参数 Schema。
|
|
641
665
|
3. 用户任务、项目根目录语义和执行边界。
|
|
642
666
|
|
|
667
|
+
同一网页会话中的普通 follow-up 只发送新的用户输入,并在末尾追加短 `<system_reminder>`;不得重复发送完整 `<agent_protocol>`、工具目录或初始任务。只有没有新用户输入的中断恢复流程可以发送完整 resume scaffold。
|
|
668
|
+
|
|
643
669
|
关键规则:
|
|
644
670
|
|
|
645
671
|
- 一轮最多一个工具调用。
|
|
@@ -816,7 +842,7 @@ macOS、Windows、Linux 都运行:
|
|
|
816
842
|
| ChatGPT DOM 变化 | Provider Adapter 隔离、集中 Locator、Fixture 和诊断截图 |
|
|
817
843
|
| 网页模型不遵守 XML | 简单协议、单工具/轮、确定性解析、有限重发 |
|
|
818
844
|
| 重复执行工具 | assistant 指纹、call ledger、两阶段事件记录 |
|
|
819
|
-
| 大输出塞满聊天 |
|
|
845
|
+
| 大输出塞满聊天 | 文件按 16 KiB 分段读取;命令结果回填 4 KiB;浏览器工具消息硬限制 24 KiB;本地命令日志限制 4 MiB |
|
|
820
846
|
| dev server 阻塞 | 独立 Process Manager |
|
|
821
847
|
| 跨平台 Shell 差异 | `program + argv + cwd`,复杂 Shell 单独审批 |
|
|
822
848
|
| 路径逃逸 | realpath、符号链接检查、项目根策略 |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wtagent",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.3",
|
|
4
4
|
"description": "Turn your own web AI session into a local tool-using agent.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -25,12 +25,14 @@
|
|
|
25
25
|
"files": [
|
|
26
26
|
"src",
|
|
27
27
|
"README.md",
|
|
28
|
-
"docs/technical-design.md"
|
|
28
|
+
"docs/technical-design.md",
|
|
29
|
+
"LICENSE"
|
|
29
30
|
],
|
|
30
31
|
"scripts": {
|
|
31
32
|
"test": "node --test",
|
|
32
33
|
"test:unit": "node --test test/protocol.test.js test/policy.test.js test/tools.test.js test/prompt-builder.test.js test/session-export.test.js test/platform.test.js test/cli.test.js test/at-files.test.js",
|
|
33
34
|
"test:integration": "node --test test/runtime.test.js",
|
|
35
|
+
"test:windows": "node --test test/windows-command-launcher.test.js test/windows-diagnostics.test.js test/windows-search-path.test.js test/atomic-write.test.js test/process-utils.test.js test/cdp-state.test.js test/windows-fixtures.test.js test/windows-package-smoke.test.js",
|
|
34
36
|
"check": "node scripts/check-syntax.js",
|
|
35
37
|
"doctor": "node src/cli/main.js doctor",
|
|
36
38
|
"login": "node src/cli/main.js login",
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
discoverReusableCdpState,
|
|
8
8
|
fetchCdpVersion,
|
|
9
9
|
processMatchesCdpState,
|
|
10
|
+
reapStaleProfileChrome,
|
|
10
11
|
removeCdpState,
|
|
11
12
|
saveCdpState,
|
|
12
13
|
waitForProcessExit,
|
|
@@ -45,6 +46,33 @@ async function settleWithin(promise, timeoutMs) {
|
|
|
45
46
|
}
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
// Races a promise against a timeout. On timeout the returned promise rejects
|
|
50
|
+
// with a TIMEOUT-tagged error. Used to bound connectOverCDP + the first CDP
|
|
51
|
+
// round-trip: a Chrome whose profile is locked by a stale instance answers the
|
|
52
|
+
// WS handshake but never finishes protocol init, so an unbounded connect hangs
|
|
53
|
+
// for the full Playwright default (30s) before failing.
|
|
54
|
+
class CdpTimeoutError extends Error {
|
|
55
|
+
constructor(message) {
|
|
56
|
+
super(message);
|
|
57
|
+
this.name = "CdpTimeoutError";
|
|
58
|
+
this.code = "CDP_CONNECT_TIMEOUT";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function withTimeout(promise, timeoutMs, message) {
|
|
63
|
+
let timer;
|
|
64
|
+
try {
|
|
65
|
+
return await Promise.race([
|
|
66
|
+
promise,
|
|
67
|
+
new Promise((_, reject) => {
|
|
68
|
+
timer = setTimeout(() => reject(new CdpTimeoutError(message)), timeoutMs);
|
|
69
|
+
}),
|
|
70
|
+
]);
|
|
71
|
+
} finally {
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
48
76
|
async function waitForCdp({
|
|
49
77
|
endpoint,
|
|
50
78
|
child,
|
|
@@ -116,12 +144,14 @@ export async function launchAndConnectCdpChrome({
|
|
|
116
144
|
fetchVersion = fetchCdpVersion,
|
|
117
145
|
killTree = killProcessTree,
|
|
118
146
|
matchesState = processMatchesCdpState,
|
|
147
|
+
reapStale = reapStaleProfileChrome,
|
|
119
148
|
removeState = removeCdpState,
|
|
120
149
|
reserveCdpPort = reservePort,
|
|
121
150
|
saveState = saveCdpState,
|
|
122
151
|
spawnChrome = spawn,
|
|
123
152
|
waitForExit = waitForProcessExit,
|
|
124
153
|
waitForReady = waitForCdp,
|
|
154
|
+
connectTimeoutMs = 20_000,
|
|
125
155
|
} = {}) {
|
|
126
156
|
const releaseProfileLock = await acquireProfileLock(profileDir);
|
|
127
157
|
let child = null;
|
|
@@ -140,6 +170,11 @@ export async function launchAndConnectCdpChrome({
|
|
|
140
170
|
reused = true;
|
|
141
171
|
} else {
|
|
142
172
|
await removeState(profileDir);
|
|
173
|
+
// A prior instance may have died leaving renderer children (and Chrome's
|
|
174
|
+
// SingletonLock) still holding this profile. Reap those stale holders
|
|
175
|
+
// before launching, or the new Chrome hangs during profile init and
|
|
176
|
+
// connectOverCDP times out.
|
|
177
|
+
await reapStale(profileDir, { fetchVersion, killTree }).catch(() => null);
|
|
143
178
|
const port = await reserveCdpPort();
|
|
144
179
|
const endpoint = `http://127.0.0.1:${port}`;
|
|
145
180
|
child = spawnChrome(
|
|
@@ -192,8 +227,41 @@ export async function launchAndConnectCdpChrome({
|
|
|
192
227
|
}
|
|
193
228
|
}
|
|
194
229
|
|
|
195
|
-
|
|
196
|
-
|
|
230
|
+
// Bound the connect + first CDP round-trip. If Chrome's profile is held by
|
|
231
|
+
// a stale instance, the WS connects but protocol init never completes;
|
|
232
|
+
// without this guard Playwright hangs ~30s and leaves a dirty CDP state.
|
|
233
|
+
let context;
|
|
234
|
+
try {
|
|
235
|
+
browser = await withTimeout(
|
|
236
|
+
connectOverCDP(state.endpoint),
|
|
237
|
+
connectTimeoutMs,
|
|
238
|
+
`Timed out connecting to Chrome CDP at ${state.endpoint} after ${connectTimeoutMs}ms.`,
|
|
239
|
+
);
|
|
240
|
+
// contexts() forces a real protocol round-trip, so it hangs too when the
|
|
241
|
+
// browser main thread is stuck — keep it inside the timeout budget.
|
|
242
|
+
const contexts = await withTimeout(
|
|
243
|
+
Promise.resolve().then(() => browser.contexts()),
|
|
244
|
+
connectTimeoutMs,
|
|
245
|
+
`Timed out reading Chrome browser context at ${state.endpoint}.`,
|
|
246
|
+
);
|
|
247
|
+
context = contexts[0];
|
|
248
|
+
} catch (error) {
|
|
249
|
+
if (error instanceof CdpTimeoutError) {
|
|
250
|
+
// The verified-but-unusable instance we launched is a dead end. Kill it
|
|
251
|
+
// (only if we own it) and drop its CDP state so the next run starts
|
|
252
|
+
// clean instead of trying to reuse a hung endpoint.
|
|
253
|
+
await browser?.close().catch(() => null);
|
|
254
|
+
if (!reused && child?.pid) {
|
|
255
|
+
await killTree(child.pid).catch(() => null);
|
|
256
|
+
}
|
|
257
|
+
await removeState(profileDir, state).catch(() => null);
|
|
258
|
+
throw new Error(
|
|
259
|
+
`${error.message} The Chrome profile may be held by another instance. `
|
|
260
|
+
+ "Close other windows using this profile, or run `wtagent logout` to reset it, then retry.",
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
throw error;
|
|
264
|
+
}
|
|
197
265
|
if (!context) {
|
|
198
266
|
throw new Error("Chrome CDP connection did not expose a browser context.");
|
|
199
267
|
}
|
package/src/browser/cdp-state.js
CHANGED
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { execFile } from "node:child_process";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
|
+
import { replaceFileAtomic } from "../shared/atomic-write.js";
|
|
6
7
|
|
|
7
8
|
const execFileAsync = promisify(execFile);
|
|
8
9
|
const CDP_STATE_FILE = ".wtagent-cdp.json";
|
|
@@ -37,7 +38,7 @@ async function writeJsonAtomic(filePath, value) {
|
|
|
37
38
|
`${JSON.stringify(value, null, 2)}\n`,
|
|
38
39
|
{ mode: 0o600 },
|
|
39
40
|
);
|
|
40
|
-
await
|
|
41
|
+
await replaceFileAtomic(temporary, filePath);
|
|
41
42
|
} finally {
|
|
42
43
|
await fs.rm(temporary, { force: true }).catch(() => {});
|
|
43
44
|
}
|
|
@@ -114,7 +115,7 @@ function normalizeCandidate(candidate, profileDir) {
|
|
|
114
115
|
}
|
|
115
116
|
if (
|
|
116
117
|
candidate.profileDir
|
|
117
|
-
&&
|
|
118
|
+
&& !profileDirsMatch(candidate.profileDir, profileDir)
|
|
118
119
|
) {
|
|
119
120
|
return null;
|
|
120
121
|
}
|
|
@@ -128,6 +129,21 @@ function normalizeCandidate(candidate, profileDir) {
|
|
|
128
129
|
};
|
|
129
130
|
}
|
|
130
131
|
|
|
132
|
+
function normalizeProfilePath(value, platform = process.platform) {
|
|
133
|
+
const input = String(value ?? "");
|
|
134
|
+
const resolved = platform === "win32"
|
|
135
|
+
? path.win32.resolve(input)
|
|
136
|
+
: path.resolve(input);
|
|
137
|
+
if (platform !== "win32") {
|
|
138
|
+
return resolved;
|
|
139
|
+
}
|
|
140
|
+
return path.win32.normalize(resolved).replaceAll("/", "\\").toLowerCase();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function profileDirsMatch(left, right, platform = process.platform) {
|
|
144
|
+
return normalizeProfilePath(left, platform) === normalizeProfilePath(right, platform);
|
|
145
|
+
}
|
|
146
|
+
|
|
131
147
|
async function probeCandidate(candidate, profileDir, {
|
|
132
148
|
isAlive = isProcessAlive,
|
|
133
149
|
fetchVersion = fetchCdpVersion,
|
|
@@ -202,7 +218,7 @@ async function readProcessTable() {
|
|
|
202
218
|
"-Command",
|
|
203
219
|
"Get-CimInstance Win32_Process | Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress",
|
|
204
220
|
],
|
|
205
|
-
{ maxBuffer: 10 * 1024 * 1024 },
|
|
221
|
+
{ maxBuffer: 10 * 1024 * 1024, windowsHide: true },
|
|
206
222
|
);
|
|
207
223
|
const parsed = JSON.parse(stdout || "[]");
|
|
208
224
|
return (Array.isArray(parsed) ? parsed : [parsed]).map((entry) => ({
|
|
@@ -219,24 +235,25 @@ async function readProcessTable() {
|
|
|
219
235
|
return stdout.split("\n").flatMap((line) => {
|
|
220
236
|
const match = line.match(/^\s*(\d+)\s+(.+)$/);
|
|
221
237
|
return match
|
|
222
|
-
? [{ pid: Number(match[1]), command: match[2] }]
|
|
238
|
+
? [{ pid: Number(match[1]), command: match[2].trim() }]
|
|
223
239
|
: [];
|
|
224
240
|
});
|
|
225
241
|
}
|
|
226
242
|
|
|
227
|
-
function commandUsesProfile(command, profileDir) {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
243
|
+
function commandUsesProfile(command, profileDir, platform = process.platform) {
|
|
244
|
+
const inline = command.match(/--user-data-dir=(?:"([^"]+)"|'([^']+)'|(\S+))/i);
|
|
245
|
+
const separated = command.match(/--user-data-dir\s+(?:"([^"]+)"|'([^']+)'|(\S+))/i);
|
|
246
|
+
const raw = inline?.[1] ?? inline?.[2] ?? inline?.[3]
|
|
247
|
+
?? separated?.[1] ?? separated?.[2] ?? separated?.[3]
|
|
248
|
+
?? null;
|
|
249
|
+
return raw ? profileDirsMatch(raw, profileDir, platform) : false;
|
|
233
250
|
}
|
|
234
251
|
|
|
235
|
-
function candidateFromProcess(entry, profileDir) {
|
|
252
|
+
function candidateFromProcess(entry, profileDir, platform = process.platform) {
|
|
236
253
|
if (
|
|
237
254
|
!entry.command
|
|
238
255
|
|| entry.command.includes("--type=")
|
|
239
|
-
|| !commandUsesProfile(entry.command, profileDir)
|
|
256
|
+
|| !commandUsesProfile(entry.command, profileDir, platform)
|
|
240
257
|
) {
|
|
241
258
|
return null;
|
|
242
259
|
}
|
|
@@ -246,6 +263,16 @@ function candidateFromProcess(entry, profileDir) {
|
|
|
246
263
|
: null;
|
|
247
264
|
}
|
|
248
265
|
|
|
266
|
+
function profileHolderFromProcess(entry, profileDir, platform = process.platform) {
|
|
267
|
+
if (!entry.command || !commandUsesProfile(entry.command, profileDir, platform)) {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
const pid = Number(entry.pid);
|
|
271
|
+
return Number.isSafeInteger(pid) && pid > 0
|
|
272
|
+
? { pid, command: entry.command }
|
|
273
|
+
: null;
|
|
274
|
+
}
|
|
275
|
+
|
|
249
276
|
async function singletonOwnerPid(profileDir) {
|
|
250
277
|
if (process.platform === "win32") {
|
|
251
278
|
return null;
|
|
@@ -259,13 +286,17 @@ async function singletonOwnerPid(profileDir) {
|
|
|
259
286
|
}
|
|
260
287
|
}
|
|
261
288
|
|
|
262
|
-
export async function processMatchesCdpState(state
|
|
263
|
-
|
|
289
|
+
export async function processMatchesCdpState(state, {
|
|
290
|
+
listProcesses = readProcessTable,
|
|
291
|
+
platform = process.platform,
|
|
292
|
+
} = {}) {
|
|
293
|
+
const pathApi = platform === "win32" ? path.win32 : path.posix;
|
|
294
|
+
const profileDir = pathApi.resolve(state.profileDir);
|
|
264
295
|
const port = Number(state.port);
|
|
265
296
|
try {
|
|
266
|
-
const processes = await
|
|
297
|
+
const processes = await listProcesses();
|
|
267
298
|
return processes.some((entry) => {
|
|
268
|
-
const candidate = candidateFromProcess(entry, profileDir);
|
|
299
|
+
const candidate = candidateFromProcess(entry, profileDir, platform);
|
|
269
300
|
return (
|
|
270
301
|
candidate?.pid === Number(state.pid)
|
|
271
302
|
&& candidate.port === port
|
|
@@ -276,30 +307,61 @@ export async function processMatchesCdpState(state) {
|
|
|
276
307
|
}
|
|
277
308
|
}
|
|
278
309
|
|
|
310
|
+
function processTableContainsState(
|
|
311
|
+
processes,
|
|
312
|
+
state,
|
|
313
|
+
profileDir,
|
|
314
|
+
platform = process.platform,
|
|
315
|
+
) {
|
|
316
|
+
const port = Number(state.port);
|
|
317
|
+
const pid = Number(state.pid);
|
|
318
|
+
return processes.some((entry) => {
|
|
319
|
+
const candidate = candidateFromProcess(entry, profileDir, platform);
|
|
320
|
+
return candidate?.pid === pid && candidate.port === port;
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
279
324
|
export async function discoverReusableCdpState(profileDir, {
|
|
280
325
|
isAlive = isProcessAlive,
|
|
281
326
|
fetchVersion = fetchCdpVersion,
|
|
282
327
|
listProcesses = readProcessTable,
|
|
328
|
+
platform = process.platform,
|
|
283
329
|
} = {}) {
|
|
284
330
|
const resolvedProfile = path.resolve(profileDir);
|
|
331
|
+
const requireVerifiedProcessTable = platform === "win32";
|
|
285
332
|
const saved = await readCdpState(resolvedProfile);
|
|
333
|
+
let processes;
|
|
334
|
+
try {
|
|
335
|
+
processes = await listProcesses();
|
|
336
|
+
} catch {
|
|
337
|
+
if (requireVerifiedProcessTable) {
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
processes = null;
|
|
341
|
+
}
|
|
342
|
+
|
|
286
343
|
const savedHealthy = await probeCandidate(saved, resolvedProfile, {
|
|
287
344
|
isAlive,
|
|
288
345
|
fetchVersion,
|
|
289
346
|
});
|
|
290
|
-
if (
|
|
347
|
+
if (
|
|
348
|
+
savedHealthy
|
|
349
|
+
&& (!requireVerifiedProcessTable || processTableContainsState(
|
|
350
|
+
processes ?? [],
|
|
351
|
+
savedHealthy,
|
|
352
|
+
resolvedProfile,
|
|
353
|
+
platform,
|
|
354
|
+
))
|
|
355
|
+
) {
|
|
291
356
|
return await saveCdpState(resolvedProfile, savedHealthy);
|
|
292
357
|
}
|
|
293
358
|
|
|
294
|
-
|
|
295
|
-
try {
|
|
296
|
-
processes = await listProcesses();
|
|
297
|
-
} catch {
|
|
359
|
+
if (!processes) {
|
|
298
360
|
return null;
|
|
299
361
|
}
|
|
300
362
|
|
|
301
363
|
const rawCandidates = processes
|
|
302
|
-
.map((entry) => candidateFromProcess(entry, resolvedProfile))
|
|
364
|
+
.map((entry) => candidateFromProcess(entry, resolvedProfile, platform))
|
|
303
365
|
.filter(Boolean);
|
|
304
366
|
const uniqueCandidates = [...new Map(
|
|
305
367
|
rawCandidates.map((candidate) => [
|
|
@@ -335,6 +397,150 @@ export async function discoverReusableCdpState(profileDir, {
|
|
|
335
397
|
);
|
|
336
398
|
}
|
|
337
399
|
|
|
400
|
+
// Kills Chrome processes bound to this profile whose CDP endpoint is dead, and
|
|
401
|
+
// clears Chrome's singleton guard files. A half-dead prior instance (main
|
|
402
|
+
// process gone or unresponsive, but renderer children still holding the
|
|
403
|
+
// profile) makes a freshly launched Chrome hang during profile initialization:
|
|
404
|
+
// the new CDP port answers HTTP/WS, but the browser main thread never becomes
|
|
405
|
+
// usable, so connectOverCDP times out. Reaping those stale holders first
|
|
406
|
+
// prevents the hang. Only ever touches processes that use THIS profile dir, and
|
|
407
|
+
// never a live/healthy CDP instance. Best-effort.
|
|
408
|
+
export async function reapStaleProfileChrome(profileDir, {
|
|
409
|
+
isAlive = isProcessAlive,
|
|
410
|
+
fetchVersion = fetchCdpVersion,
|
|
411
|
+
listProcesses = readProcessTable,
|
|
412
|
+
killTree = null,
|
|
413
|
+
platform = process.platform,
|
|
414
|
+
} = {}) {
|
|
415
|
+
const resolvedProfile = path.resolve(profileDir);
|
|
416
|
+
let processes;
|
|
417
|
+
try {
|
|
418
|
+
processes = await listProcesses();
|
|
419
|
+
} catch {
|
|
420
|
+
return { killed: [] };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const holders = processes
|
|
424
|
+
.map((entry) => profileHolderFromProcess(entry, resolvedProfile, platform))
|
|
425
|
+
.filter(Boolean);
|
|
426
|
+
const candidates = processes
|
|
427
|
+
.map((entry) => candidateFromProcess(entry, resolvedProfile, platform))
|
|
428
|
+
.filter(Boolean);
|
|
429
|
+
|
|
430
|
+
// A healthy main browser owns every helper/renderer using this profile.
|
|
431
|
+
// Never reap individual children from a verified live instance.
|
|
432
|
+
let hasHealthyCdpOwner = false;
|
|
433
|
+
for (const candidate of candidates) {
|
|
434
|
+
try {
|
|
435
|
+
await fetchVersion(`http://127.0.0.1:${candidate.port}`);
|
|
436
|
+
hasHealthyCdpOwner = true;
|
|
437
|
+
break;
|
|
438
|
+
} catch {
|
|
439
|
+
// Continue until one verified owner is found.
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
if (hasHealthyCdpOwner) {
|
|
443
|
+
return { killed: [] };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const killed = [];
|
|
447
|
+
for (const holder of holders) {
|
|
448
|
+
if (isAlive(holder.pid) && typeof killTree === "function") {
|
|
449
|
+
await killTree(holder.pid).catch(() => {});
|
|
450
|
+
}
|
|
451
|
+
killed.push(holder.pid);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Chrome's singleton profile guard is a symlink; a crashed instance can leave
|
|
455
|
+
// it dangling and block the next launch. Clear it when we reaped holders or
|
|
456
|
+
// when it points at a dead pid.
|
|
457
|
+
const ownerPid = await singletonOwnerPid(resolvedProfile);
|
|
458
|
+
if (killed.length > 0 || (ownerPid && !isAlive(ownerPid))) {
|
|
459
|
+
for (const name of ["SingletonLock", "SingletonCookie", "SingletonSocket"]) {
|
|
460
|
+
await fs.rm(path.join(resolvedProfile, name), { force: true })
|
|
461
|
+
.catch(() => {});
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return { killed };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
export async function inspectCdpProfileState(profileDir, {
|
|
469
|
+
isAlive = isProcessAlive,
|
|
470
|
+
fetchVersion = fetchCdpVersion,
|
|
471
|
+
listProcesses = readProcessTable,
|
|
472
|
+
platform = process.platform,
|
|
473
|
+
} = {}) {
|
|
474
|
+
const resolvedProfile = path.resolve(profileDir);
|
|
475
|
+
const diagnostics = [];
|
|
476
|
+
|
|
477
|
+
const lock = await readJson(lockPath(resolvedProfile));
|
|
478
|
+
if (lock?.pid) {
|
|
479
|
+
const alive = isAlive(Number(lock.pid));
|
|
480
|
+
diagnostics.push(
|
|
481
|
+
alive
|
|
482
|
+
? `profile lock held by live pid=${lock.pid}`
|
|
483
|
+
: `stale profile lock references dead pid=${lock.pid}`,
|
|
484
|
+
);
|
|
485
|
+
} else {
|
|
486
|
+
diagnostics.push("no WTAgent profile lock");
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const saved = await readJson(statePath(resolvedProfile));
|
|
490
|
+
if (!saved) {
|
|
491
|
+
diagnostics.push("no saved CDP state");
|
|
492
|
+
return {
|
|
493
|
+
status: "pass",
|
|
494
|
+
detail: diagnostics.join("; "),
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const savedHealthy = await probeCandidate(saved, resolvedProfile, {
|
|
499
|
+
isAlive,
|
|
500
|
+
fetchVersion,
|
|
501
|
+
});
|
|
502
|
+
if (!savedHealthy) {
|
|
503
|
+
diagnostics.push(`saved CDP state for pid=${saved.pid} is stale or unhealthy`);
|
|
504
|
+
return {
|
|
505
|
+
status: "degraded",
|
|
506
|
+
detail: diagnostics.join("; "),
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if (platform !== "win32") {
|
|
511
|
+
diagnostics.push(`saved CDP state is healthy for pid=${savedHealthy.pid}`);
|
|
512
|
+
return {
|
|
513
|
+
status: "pass",
|
|
514
|
+
detail: diagnostics.join("; "),
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
let processes;
|
|
519
|
+
try {
|
|
520
|
+
processes = await listProcesses();
|
|
521
|
+
} catch (error) {
|
|
522
|
+
diagnostics.push(`cannot verify saved CDP state against PowerShell CIM: ${error.message}`);
|
|
523
|
+
return {
|
|
524
|
+
status: "degraded",
|
|
525
|
+
detail: diagnostics.join("; "),
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if (!processTableContainsState(processes, savedHealthy, resolvedProfile, platform)) {
|
|
530
|
+
diagnostics.push(`saved CDP state for pid=${savedHealthy.pid} could not be verified against the current process table`);
|
|
531
|
+
return {
|
|
532
|
+
status: "degraded",
|
|
533
|
+
detail: diagnostics.join("; "),
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
diagnostics.push(`saved CDP state is verified for pid=${savedHealthy.pid}`);
|
|
538
|
+
return {
|
|
539
|
+
status: "pass",
|
|
540
|
+
detail: diagnostics.join("; "),
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
|
|
338
544
|
export async function acquireCdpProfileLock(profileDir, {
|
|
339
545
|
ownerPid = process.pid,
|
|
340
546
|
isAlive = isProcessAlive,
|