cdpilot 0.9.5 → 0.9.6
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 +128 -1
- package/bin/cdpilot.js +375 -18
- package/package.json +1 -1
- package/src/cdpilot.py +1471 -128
package/README.md
CHANGED
|
@@ -148,7 +148,26 @@ tools (navigate, click, fill, content, screenshot, eval, wait_for, assert, tabs,
|
|
|
148
148
|
plus `browser_tools`. The agent calls `browser_tools` with `list` to see the other
|
|
149
149
|
tools, or `enable` to add some (the server sends `tools/list_changed`). `--tools a,b,c`
|
|
150
150
|
picks tools by name; `all` (default) is unchanged. Schema size of `tools/list`
|
|
151
|
-
(tiktoken `o200k_base`): 45 tools = 5,769 tokens, `core` = 11 tools = 1,342 tokens
|
|
151
|
+
(tiktoken `o200k_base`): 45 tools = 5,769 tokens, `core` = 11 tools = 1,342 tokens; the four
|
|
152
|
+
`browser_agent_*` tools add ~11% of schema (49 tools ≈ 6,400 tokens, scaled from the measurement).
|
|
153
|
+
|
|
154
|
+
### Agent mode over MCP
|
|
155
|
+
|
|
156
|
+
`browser_agent_observe` (`diff`), `browser_agent_act` (`ref`/`url`, `action`, `text`), `browser_agent_flow_save`
|
|
157
|
+
(`name`, `last`) and `browser_agent_run` (`flow`, `vars`, `dry_run`) wrap the agent commands below, so a model
|
|
158
|
+
on MCP works from numbered refs, gets diffs, and replays a learned flow with no page read at all. They are
|
|
159
|
+
outside the `core` profile; `browser_tools enable` adds them. A drift or a missing variable comes back as
|
|
160
|
+
`isError` with the command's JSON (`status:"drift"` carries the page's observation).
|
|
161
|
+
|
|
162
|
+
Set `CDPILOT_DAEMON=1` in the MCP server's environment and every tool call runs in the server process
|
|
163
|
+
instead of a fresh Python process: ~10 ms before the CDP work instead of ~400 ms (`browser_eval` /
|
|
164
|
+
`browser_tabs` / `browser_content` medians 11 / 7 / 9 ms against 412 / 390 / 416 ms, M1 Max under load).
|
|
165
|
+
Same rules as the warm process below — same output, exit codes and session log; lifecycle tools
|
|
166
|
+
(`browser_launch`, `browser_connect`) still run fresh; a call past 30 s is cancelled.
|
|
167
|
+
|
|
168
|
+
```json
|
|
169
|
+
{ "mcpServers": { "cdpilot": { "command": "npx", "args": ["cdpilot", "mcp"], "env": { "CDPILOT_DAEMON": "1" } } } }
|
|
170
|
+
```
|
|
152
171
|
|
|
153
172
|
### MCP Bundle (.mcpb)
|
|
154
173
|
|
|
@@ -453,6 +472,70 @@ cdpilot a11y summary # Quick summary
|
|
|
453
472
|
cdpilot a11y find <role> # Find elements by ARIA role
|
|
454
473
|
```
|
|
455
474
|
|
|
475
|
+
### Agent mode: numbered refs and diffs (token budget)
|
|
476
|
+
|
|
477
|
+
```bash
|
|
478
|
+
cdpilot agent observe # {url, title, actions:[{ref:"@1", role, name, value?}], text, token_estimate}
|
|
479
|
+
cdpilot agent act --ref @3 --action click
|
|
480
|
+
cdpilot agent act --ref @4 --action fill --text "cdpilot"
|
|
481
|
+
cdpilot agent act --url https://example.com/ # navigates silently, prints the diff
|
|
482
|
+
cdpilot agent observe --diff # only what changed since the last observe/act
|
|
483
|
+
cdpilot agent stats | agent reset
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
`observe` turns the accessibility tree into a numbered list of things an agent can act on (buttons, links,
|
|
487
|
+
fields with their current value, unique text anchors) plus a short text summary; no screenshot, no HTML.
|
|
488
|
+
Every `act` and `observe --diff` returns only the change: `changed.added` (full descriptors, so the agent can act
|
|
489
|
+
on a new element without another observe), `changed.removed` (refs), `changed.value_changed`, and `text` only
|
|
490
|
+
when it changed (`text_changed`). Refs are stable while the element exists (keyed by the DOM node, not by
|
|
491
|
+
position); a navigation starts a new set. `token_estimate` is `len(json)/4`; `saved_vs_full` compares the diff
|
|
492
|
+
with a full observe of the same page. State lives in `~/.cdpilot/projects/<id>/agent-state.json`.
|
|
493
|
+
|
|
494
|
+
#### Learned flows: drive a site once, replay it with no model
|
|
495
|
+
|
|
496
|
+
```bash
|
|
497
|
+
cdpilot agent observe && cdpilot agent act --ref @3 --action click && cdpilot agent act --ref @4 --action fill --text "cdpilot"
|
|
498
|
+
cdpilot agent flow save inbox-triage # the acts since the last navigation → ~/.cdpilot/projects/<id>/flows/inbox_triage.json
|
|
499
|
+
cdpilot agent run inbox-triage # replays by anchors; prints one JSON: {status:"ok", steps, url, title, ms, tokens_avoided}
|
|
500
|
+
cdpilot agent run login --var password=… # a typed secret was saved as {{password}}; --var supplies it, or CDPILOT_VAR_PASSWORD
|
|
501
|
+
# with CDPILOT_VAR_HOSTS=example.com (the host the value was recorded on)
|
|
502
|
+
cdpilot agent flow list | show <name> | delete <name>
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
Every `agent act` leaves a trail entry addressed by an *anchor*, the element's accessibility role, name and
|
|
506
|
+
position among equals (`{"role":"button","name":"Archive","nth":0}`), never a `@ref` or a DOM node id, which
|
|
507
|
+
belong to one session. `agent flow save` writes the trail as a flow; `agent run` finds each anchor on the live
|
|
508
|
+
page (exact name, then case-insensitive, then a longer name containing it when that is unambiguous) and acts,
|
|
509
|
+
with no model in the loop. The result's `tokens_avoided` is what a model would have read observing the page
|
|
510
|
+
before each step. When a site has a flow, `agent observe` lists it under `known_flows`.
|
|
511
|
+
|
|
512
|
+
**Drift stops, never guesses.** If a step's anchor is gone, or the step lands on another host than it did
|
|
513
|
+
when recorded, `agent run` exits 4 and prints that page's full observation (`status:"drift"`, `step`,
|
|
514
|
+
`expected`, `actions`, `text`): the agent acts on those refs, finishes the task, and `agent flow save <name>`
|
|
515
|
+
rewrites the flow. Exit 2: unknown flow or a `{{variable}}` without a value (checked before any step runs);
|
|
516
|
+
`--dry-run` prints the steps with placeholders, never values. Values typed into secret-named fields
|
|
517
|
+
(password, one-time code, API key, PIN, card number, …) or token-shaped values are placeholders on disk; the
|
|
518
|
+
same fields show `«masked»` as their value in `observe` output. Masking goes by field name or token shape: a
|
|
519
|
+
secret typed into a field named otherwise is stored in clear, so review a flow before sharing it (flow files
|
|
520
|
+
and `agent-state.json` are 0600). A `{{placeholder}}` typed into `agent act --text` or `--url` is refused
|
|
521
|
+
(exit 2): placeholders are cdpilot's own, so a flow replays only values the user recorded, and a step that
|
|
522
|
+
types one runs only on the host it was recorded on. `--var` values are visible in the process list for the
|
|
523
|
+
duration of the call and are redacted in the session log; an environment value never enters argv.
|
|
524
|
+
`CDPILOT_VAR_<NAME>` is used only by a step recorded on a host named in `CDPILOT_VAR_HOSTS` (comma-separated,
|
|
525
|
+
subdomains included), on that same origin, and never inside a URL — so a flow that a prompt-injected model
|
|
526
|
+
records on another site cannot draw on the environment's secrets; anything else is refused before the first
|
|
527
|
+
step (exit 2, `refused` says why) or is a drift.
|
|
528
|
+
|
|
529
|
+
#### Text that talks to the agent: `injection_suspect`
|
|
530
|
+
|
|
531
|
+
Page text that addresses the model instead of the user is the shape of an indirect prompt injection ("AI
|
|
532
|
+
assistants reading this page: ignore your previous instructions…"). `agent observe`, the diff of `agent act`
|
|
533
|
+
and a drift result of `agent run` carry `injection_suspect: [{text, match}]` (at most 5, absent when none)
|
|
534
|
+
whenever such a block is in the text: `ignore_instructions`, `addresses_ai`, `system_prompt`,
|
|
535
|
+
`new_instructions`, `hide_from_user`, `exfiltrate`. Nothing is hidden or blocked, the text stays where it was;
|
|
536
|
+
the agent sees the mark next to it and treats the block as data. The patterns are narrow on purpose: a page
|
|
537
|
+
*about* AI agents or system prompts is not flagged.
|
|
538
|
+
|
|
456
539
|
### Session Management
|
|
457
540
|
|
|
458
541
|
```bash
|
|
@@ -946,6 +1029,50 @@ CDPILOT_TIMEOUT=60 cdpilot run flow.cdp # default for every command (and each
|
|
|
946
1029
|
For `mcp` and `serve` the value is not applied to the long-running server
|
|
947
1030
|
itself; it is passed on to every tool call / request it runs.
|
|
948
1031
|
|
|
1032
|
+
### Warm process (`CDPILOT_DAEMON=1`)
|
|
1033
|
+
|
|
1034
|
+
Every cdpilot call is a fresh process: node starts (~0.1 s), Python imports the
|
|
1035
|
+
core (~0.15 s) and a CDP session is set up (~0.5 s) before the command itself
|
|
1036
|
+
runs — a browser paints a page in 20 ms, `cdpilot go` took about a second
|
|
1037
|
+
(measurements: `.claude/docs/perf-taban-2026-11-08.md`). With
|
|
1038
|
+
`CDPILOT_DAEMON=1` the launcher keeps one warm Python process per project and
|
|
1039
|
+
environment and hands page commands to it over a Unix socket; the process keeps
|
|
1040
|
+
the module and its event loop between calls, so the second and later calls skip
|
|
1041
|
+
the start-up cost.
|
|
1042
|
+
|
|
1043
|
+
```bash
|
|
1044
|
+
export CDPILOT_DAEMON=1
|
|
1045
|
+
cdpilot go https://example.com # first call: fresh process, starts the daemon
|
|
1046
|
+
cdpilot content # served warm
|
|
1047
|
+
cdpilot daemon status # pid, requests served, uptime, socket
|
|
1048
|
+
cdpilot daemon stop # stop every running daemon (they also leave after 30 min idle)
|
|
1049
|
+
```
|
|
1050
|
+
|
|
1051
|
+
Output, exit codes, the session log and recordings are the same either way:
|
|
1052
|
+
each request runs as its own process would (the caller's argv, cwd,
|
|
1053
|
+
environment and stdin; module state reset between requests; exit-time hooks
|
|
1054
|
+
run at request end; the CDP connections a command opened are closed at request
|
|
1055
|
+
end, as a process exit closed them, so no viewport, throttle or event state
|
|
1056
|
+
leaks into the next command). Closing the client (Ctrl+C) cancels the running
|
|
1057
|
+
command. The launcher runs a fresh process instead whenever the daemon is
|
|
1058
|
+
missing, busy with another call, of another version (that one is asked to
|
|
1059
|
+
leave), set up for a port or profile the registry no longer names, or the
|
|
1060
|
+
command is not a page command: `launch`, `stop`, `connect`, `mcp`, `serve`,
|
|
1061
|
+
`watch`, `replay`, `run`, `captcha-wait` and the other lifecycle commands, and
|
|
1062
|
+
anything with `--timeout`. Nothing is ever executed twice. POSIX only for now;
|
|
1063
|
+
the socket lives in `$CDPILOT_HOME/daemon/` (or `$XDG_RUNTIME_DIR` / a private
|
|
1064
|
+
temp directory when that path is too long for a Unix socket), 0600, and is used
|
|
1065
|
+
only when it and its directory belong to you. `CDPILOT_DAEMON_IDLE=<seconds>`
|
|
1066
|
+
sets the idle exit (default 1800). Opt-in in this release; the default may
|
|
1067
|
+
change once two weeks of use show no difference in error rates.
|
|
1068
|
+
|
|
1069
|
+
The MCP server (`cdpilot mcp`) reads the same variable: with `CDPILOT_DAEMON=1`
|
|
1070
|
+
in its environment it runs each tool call in the server process the same way (the
|
|
1071
|
+
same executor, no socket involved) instead of starting a Python process per
|
|
1072
|
+
call — ~400 ms → ~10 ms before the CDP work. Lifecycle tools and a project
|
|
1073
|
+
configuration that moved since the server started still run fresh; a call past
|
|
1074
|
+
30 s is cancelled at its next async step. See "Agent mode over MCP" above.
|
|
1075
|
+
|
|
949
1076
|
### Session log
|
|
950
1077
|
|
|
951
1078
|
Every command (and every MCP tool call) appends one JSON line to a local,
|
package/bin/cdpilot.js
CHANGED
|
@@ -19,6 +19,309 @@ const os = require('os');
|
|
|
19
19
|
|
|
20
20
|
const SCRIPT = path.join(__dirname, '..', 'src', 'cdpilot.py');
|
|
21
21
|
const VERSION = require('../package.json').version;
|
|
22
|
+
const SRC_DIR = path.join(__dirname, '..', 'src');
|
|
23
|
+
|
|
24
|
+
// Python runs src/cdpilot.py through the import system (runpy) instead of as a
|
|
25
|
+
// script: a script named on the command line is recompiled on every call —
|
|
26
|
+
// 0.15 s of the ~0.3 s Python start-up measured on 2026-11-08 for the 23k-line
|
|
27
|
+
// file — while an imported module keeps its bytecode in src/__pycache__/.
|
|
28
|
+
// sys.argv ends up exactly as for `python cdpilot.py <args>` (alter_sys), and
|
|
29
|
+
// an unwritable src/ only means Python skips the cache, never an error.
|
|
30
|
+
// An interpreter started with -c puts '' (the working directory) first on
|
|
31
|
+
// sys.path, so a runpy.py or json.py planted in an untrusted checkout would run
|
|
32
|
+
// before cdpilot does (2026-11-10 review); the loader drops that entry before
|
|
33
|
+
// any other import. `python src/cdpilot.py` had src/ there instead.
|
|
34
|
+
const PY_LOADER = "import sys; sys.path[:] = [p for p in sys.path if p]; sys.path.insert(0, sys.argv[1]); del sys.argv[1]; "
|
|
35
|
+
+ "import runpy; runpy.run_module('cdpilot', run_name='__main__', alter_sys=True)";
|
|
36
|
+
function pyArgs(...args) {
|
|
37
|
+
return ['-c', PY_LOADER, SRC_DIR, ...args];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The interpreter choice is remembered in CDPILOT_HOME/python.json for a day:
|
|
41
|
+
// probing candidates with `--version` cost 50-150 ms per command (a shell and
|
|
42
|
+
// a Python start per candidate tried). CDPILOT_PYTHON is part of the key, an
|
|
43
|
+
// absolute path that vanished is re-probed, and a failed spawn clears the entry.
|
|
44
|
+
const PYTHON_CACHE_TTL_MS = 24 * 3600 * 1000;
|
|
45
|
+
function pythonCachePath() {
|
|
46
|
+
return path.join(cdpilotHome(), 'python.json');
|
|
47
|
+
}
|
|
48
|
+
function readPythonCache() {
|
|
49
|
+
try {
|
|
50
|
+
const c = JSON.parse(fs.readFileSync(pythonCachePath(), 'utf8'));
|
|
51
|
+
if (c.python && c.key === (process.env.CDPILOT_PYTHON || '')
|
|
52
|
+
&& Date.now() - c.at < PYTHON_CACHE_TTL_MS
|
|
53
|
+
&& (!path.isAbsolute(c.python) || fs.existsSync(c.python))) {
|
|
54
|
+
return c.python;
|
|
55
|
+
}
|
|
56
|
+
} catch {}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
function writePythonCache(python) {
|
|
60
|
+
try {
|
|
61
|
+
fs.mkdirSync(cdpilotHome(), { recursive: true });
|
|
62
|
+
fs.writeFileSync(pythonCachePath(),
|
|
63
|
+
JSON.stringify({ python, key: process.env.CDPILOT_PYTHON || '', at: Date.now() }));
|
|
64
|
+
} catch {}
|
|
65
|
+
}
|
|
66
|
+
function clearPythonCache() {
|
|
67
|
+
try { fs.unlinkSync(pythonCachePath()); } catch {}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Warm process (daemon) ──
|
|
71
|
+
// CDPILOT_DAEMON=1: page commands go to one warm Python process per project
|
|
72
|
+
// (src/cdpilot.py --_daemon-serve) over a Unix socket; it keeps the module and
|
|
73
|
+
// the asyncio loop between calls — the CDP connections a request opened are
|
|
74
|
+
// closed at its end, as a process exit closed them (2026-11-08: a
|
|
75
|
+
// fresh process spent ~110 ms in node, ~140 ms starting Python and ~500 ms
|
|
76
|
+
// setting the CDP session up before `go` even began). The client falls back
|
|
77
|
+
// to a fresh process whenever the daemon is missing, busy (no hello within
|
|
78
|
+
// DAEMON_HELLO_MS), of another version, or refuses the command — always before
|
|
79
|
+
// anything ran, so a command never runs twice. POSIX only for now.
|
|
80
|
+
const net = require('net');
|
|
81
|
+
const DAEMON_SERVE_FLAG = '--_daemon-serve';
|
|
82
|
+
const DAEMON_HELLO_MS = 150;
|
|
83
|
+
// Mirrors DAEMON_SPAWN_CMDS in src/cdpilot.py (a test keeps them identical):
|
|
84
|
+
// lifecycle, servers and forks, long-running loops, the daemon itself.
|
|
85
|
+
const DAEMON_SPAWN_CMDS = new Set([
|
|
86
|
+
'launch', 'stop', 'stop-all', 'project-stop', 'connect', 'disconnect',
|
|
87
|
+
'serve', 'mcp', 'watch', 'setup', 'status', 'daemon', 'replay', 'run',
|
|
88
|
+
'blog', 'bot-auth', 'browser', 'test', 'trace', 'heal', 'health',
|
|
89
|
+
'session', 'sessions', 'session-close', 'projects', 'proxy', 'extensions',
|
|
90
|
+
'tls-proxy', 'captcha-wait', 'help', '--help', '-h']);
|
|
91
|
+
// "cmd sub" pairs that run fresh while the command itself is served warm
|
|
92
|
+
// (agent observe/act are page steps; agent twitter logs in and posts).
|
|
93
|
+
const DAEMON_SPAWN_SUBCMDS = new Set(['agent twitter']);
|
|
94
|
+
|
|
95
|
+
function daemonEnabled() {
|
|
96
|
+
return process.env.CDPILOT_DAEMON === '1' && process.platform !== 'win32';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function daemonEligible(cmd, args) {
|
|
100
|
+
if (!daemonEnabled() || !cmd || DAEMON_SPAWN_CMDS.has(cmd) || cmd.startsWith('--_')) return false;
|
|
101
|
+
if (DAEMON_SPAWN_SUBCMDS.has(`${cmd} ${args[1] || ''}`)) return false;
|
|
102
|
+
// --timeout ends its process with os._exit: a fresh process for it.
|
|
103
|
+
if (commandIndex(args) !== 0 || process.env.CDPILOT_TIMEOUT) return false;
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// stdin a command reads (cmd_batch: always; cmd_check: without an argument)
|
|
108
|
+
// travels inside the request; the daemon has no terminal.
|
|
109
|
+
function daemonStdin(cmd, args) {
|
|
110
|
+
if (process.stdin.isTTY) return undefined;
|
|
111
|
+
if (cmd !== 'batch' && !(cmd === 'check' && args.length === 1)) return undefined;
|
|
112
|
+
try { return fs.readFileSync(0, 'utf8'); } catch { return ''; }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// The daemon is keyed by everything src/cdpilot.py reads at import time, so a
|
|
116
|
+
// request never meets a process set up for another project, port, browser,
|
|
117
|
+
// home or interpreter: a different key is a different socket and daemon.
|
|
118
|
+
function daemonKey(env, python) {
|
|
119
|
+
const parts = { python, version: VERSION, src: SRC_DIR, home: env.HOME || '' };
|
|
120
|
+
for (const k of Object.keys(env).sort()) {
|
|
121
|
+
if (k.startsWith('CDPILOT_') || k.startsWith('CDP_') || k === 'CHROME_BIN') parts[k] = env[k];
|
|
122
|
+
}
|
|
123
|
+
return require('crypto').createHash('sha1').update(JSON.stringify(parts)).digest('hex').slice(0, 16);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// sun_path holds 104 bytes on macOS: a long CDPILOT_HOME moves the socket to
|
|
127
|
+
// a private per-user directory — $XDG_RUNTIME_DIR when set (Linux desktops),
|
|
128
|
+
// else one under the temp dir (the key still includes the home).
|
|
129
|
+
function daemonDirs() {
|
|
130
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : 0;
|
|
131
|
+
const runtime = process.env.XDG_RUNTIME_DIR
|
|
132
|
+
? path.join(process.env.XDG_RUNTIME_DIR, 'cdpilot-daemon')
|
|
133
|
+
: path.join(os.tmpdir(), `cdpilot-${uid}-daemon`);
|
|
134
|
+
return [path.join(cdpilotHome(), 'daemon'), runtime];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function daemonSockPath(key) {
|
|
138
|
+
const [home, runtime] = daemonDirs();
|
|
139
|
+
const p = path.join(home, `${key}.sock`);
|
|
140
|
+
return Buffer.byteLength(p) <= 100 ? p : path.join(runtime, `${key}.sock`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// A socket is only used when its directory and the socket itself belong to
|
|
144
|
+
// this user and nobody else may write there: in a shared temp dir another
|
|
145
|
+
// user could otherwise plant a fake daemon and receive argv, cwd and stdin.
|
|
146
|
+
function daemonPathSafe(sockPath) {
|
|
147
|
+
if (typeof process.getuid !== 'function') return true;
|
|
148
|
+
const uid = process.getuid();
|
|
149
|
+
try {
|
|
150
|
+
const dir = fs.lstatSync(path.dirname(sockPath));
|
|
151
|
+
if (!dir.isDirectory() || dir.uid !== uid || (dir.mode & 0o077) !== 0) return false;
|
|
152
|
+
} catch (e) {
|
|
153
|
+
return e.code === 'ENOENT'; // no directory yet: startDaemon creates it 0700
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
const st = fs.lstatSync(sockPath);
|
|
157
|
+
return st.isSocket() && st.uid === uid;
|
|
158
|
+
} catch (e) {
|
|
159
|
+
return e.code === 'ENOENT'; // no socket yet: the daemon may be started here
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// One request on one connection. Resolves {handled: true, code} when the
|
|
164
|
+
// daemon ran the command (every output frame went through onFrame), or
|
|
165
|
+
// {handled: false, reason} when nothing ran and the caller must spawn.
|
|
166
|
+
function daemonRequest(sockPath, payload, onFrame) {
|
|
167
|
+
return new Promise((resolve) => {
|
|
168
|
+
let settled = false, hello = false, buf = '', scanFrom = 0, exitCode = null;
|
|
169
|
+
const done = (v) => { if (!settled) { settled = true; resolve(v); } };
|
|
170
|
+
if (!daemonPathSafe(sockPath)) { done({ handled: false, reason: 'unsafe' }); return; }
|
|
171
|
+
const sock = net.createConnection(sockPath);
|
|
172
|
+
sock.setEncoding('utf8');
|
|
173
|
+
const timer = setTimeout(() => {
|
|
174
|
+
if (!hello) { sock.destroy(); done({ handled: false, reason: 'busy' }); }
|
|
175
|
+
}, DAEMON_HELLO_MS);
|
|
176
|
+
sock.on('error', (e) => {
|
|
177
|
+
clearTimeout(timer);
|
|
178
|
+
done(hello ? { handled: true, code: 1, error: `daemon connection failed: ${e.message}` }
|
|
179
|
+
: { handled: false, reason: e.code || 'error' });
|
|
180
|
+
});
|
|
181
|
+
sock.on('data', (chunk) => {
|
|
182
|
+
buf += chunk;
|
|
183
|
+
let nl;
|
|
184
|
+
while ((nl = buf.indexOf('\n', scanFrom)) >= 0) {
|
|
185
|
+
const line = buf.slice(0, nl);
|
|
186
|
+
buf = buf.slice(nl + 1);
|
|
187
|
+
scanFrom = 0;
|
|
188
|
+
let msg;
|
|
189
|
+
try { msg = JSON.parse(line); } catch { continue; }
|
|
190
|
+
if (!hello) {
|
|
191
|
+
hello = true;
|
|
192
|
+
clearTimeout(timer);
|
|
193
|
+
if (msg.version !== VERSION) {
|
|
194
|
+
// An older install's daemon: ask it to leave, run this call fresh.
|
|
195
|
+
sock.end(JSON.stringify({ op: 'shutdown' }) + '\n');
|
|
196
|
+
done({ handled: false, reason: 'version' });
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
sock.write(JSON.stringify(payload) + '\n');
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (msg.fallback !== undefined) {
|
|
203
|
+
done({ handled: false, reason: String(msg.fallback) });
|
|
204
|
+
sock.destroy();
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (msg.exit !== undefined) { exitCode = msg.exit; continue; }
|
|
208
|
+
onFrame(msg);
|
|
209
|
+
}
|
|
210
|
+
scanFrom = buf.length; // a frame longer than one chunk: do not rescan it
|
|
211
|
+
});
|
|
212
|
+
sock.on('close', () => {
|
|
213
|
+
clearTimeout(timer);
|
|
214
|
+
if (exitCode !== null) done({ handled: true, code: exitCode });
|
|
215
|
+
else done(hello ? { handled: true, code: 1, error: 'connection to the daemon was lost' }
|
|
216
|
+
: { handled: false, reason: 'closed' });
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Fork the warm process for this key, detached; at most once per 5 s. The
|
|
222
|
+
// stamp is created exclusively (no symlink following, no second starter).
|
|
223
|
+
function startDaemon(sockPath, python, env) {
|
|
224
|
+
if (!daemonPathSafe(sockPath)) return false;
|
|
225
|
+
const stamp = `${sockPath}.starting`;
|
|
226
|
+
try {
|
|
227
|
+
fs.mkdirSync(path.dirname(sockPath), { recursive: true, mode: 0o700 });
|
|
228
|
+
let fd;
|
|
229
|
+
try {
|
|
230
|
+
fd = fs.openSync(stamp, 'wx', 0o600);
|
|
231
|
+
} catch (e) {
|
|
232
|
+
if (e.code !== 'EEXIST') return false;
|
|
233
|
+
try { if (Date.now() - fs.lstatSync(stamp).mtimeMs < 5000) return false; } catch { return false; }
|
|
234
|
+
fs.unlinkSync(stamp); // a starter that never got to bind: try once more
|
|
235
|
+
fd = fs.openSync(stamp, 'wx', 0o600);
|
|
236
|
+
}
|
|
237
|
+
fs.writeSync(fd, String(process.pid));
|
|
238
|
+
fs.closeSync(fd);
|
|
239
|
+
const child = spawn(python, pyArgs(DAEMON_SERVE_FLAG, sockPath),
|
|
240
|
+
{ detached: true, stdio: 'ignore', env });
|
|
241
|
+
child.on('error', () => {});
|
|
242
|
+
child.unref();
|
|
243
|
+
return true;
|
|
244
|
+
} catch {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function daemonPing(sockPath) {
|
|
250
|
+
return new Promise((resolve) => {
|
|
251
|
+
let pong = null;
|
|
252
|
+
daemonRequest(sockPath, { op: 'ping' }, (m) => { if (m.pong) pong = m; })
|
|
253
|
+
.then(() => resolve(pong));
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function listDaemonSockets() {
|
|
258
|
+
const out = [];
|
|
259
|
+
for (const dir of daemonDirs()) {
|
|
260
|
+
let names = [];
|
|
261
|
+
try { names = fs.readdirSync(dir); } catch { continue; }
|
|
262
|
+
for (const n of names) if (n.endsWith('.sock')) out.push(path.join(dir, n));
|
|
263
|
+
}
|
|
264
|
+
return out;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// `cdpilot daemon start|stop|status`
|
|
268
|
+
async function daemonCommand(sub, python, env) {
|
|
269
|
+
const what = sub[0] || 'status';
|
|
270
|
+
const mine = daemonSockPath(daemonKey(env, python));
|
|
271
|
+
if (what === 'start') {
|
|
272
|
+
if (process.platform === 'win32') {
|
|
273
|
+
console.error('cdpilot daemon: not available on Windows yet');
|
|
274
|
+
return 1;
|
|
275
|
+
}
|
|
276
|
+
let pong = await daemonPing(mine);
|
|
277
|
+
if (!pong) {
|
|
278
|
+
startDaemon(mine, python, env);
|
|
279
|
+
for (let i = 0; i < 30 && !pong; i++) {
|
|
280
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
281
|
+
pong = await daemonPing(mine);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (!pong) {
|
|
285
|
+
console.error('cdpilot daemon: did not start (see `cdpilot daemon status`)');
|
|
286
|
+
return 1;
|
|
287
|
+
}
|
|
288
|
+
console.log(`daemon running: pid ${pong.pid}, socket ${mine}`);
|
|
289
|
+
if (!daemonEnabled()) console.log('set CDPILOT_DAEMON=1 to send commands to it');
|
|
290
|
+
return 0;
|
|
291
|
+
}
|
|
292
|
+
if (what === 'stop') {
|
|
293
|
+
let n = 0;
|
|
294
|
+
for (const sock of listDaemonSockets()) {
|
|
295
|
+
const r = await daemonRequest(sock, { op: 'shutdown' }, () => {});
|
|
296
|
+
if (r.handled) n++;
|
|
297
|
+
else if (r.reason === 'ECONNREFUSED' || r.reason === 'ENOENT') {
|
|
298
|
+
try { fs.unlinkSync(sock); } catch {} // nobody listens: a leftover of a crash
|
|
299
|
+
} // busy (no hello in time): leave it, it is serving a command
|
|
300
|
+
}
|
|
301
|
+
console.log(n ? `stopped ${n} daemon${n === 1 ? '' : 's'}` : 'no daemon running');
|
|
302
|
+
return 0;
|
|
303
|
+
}
|
|
304
|
+
if (what === 'status') {
|
|
305
|
+
const socks = listDaemonSockets();
|
|
306
|
+
let live = 0;
|
|
307
|
+
for (const sock of socks) {
|
|
308
|
+
const pong = await daemonPing(sock);
|
|
309
|
+
if (!pong) continue;
|
|
310
|
+
live++;
|
|
311
|
+
const up = Math.max(0, Math.round(Date.now() / 1000 - pong.started));
|
|
312
|
+
console.log(`${sock === mine ? '*' : ' '} pid ${pong.pid} served ${pong.served} up ${up}s idle-exit ${pong.idle}s ${sock}`);
|
|
313
|
+
}
|
|
314
|
+
if (!live) {
|
|
315
|
+
console.log('no daemon running' + (daemonEnabled() ? '' : ' (CDPILOT_DAEMON=1 starts one on the next command)'));
|
|
316
|
+
console.log(` socket: ${mine}`);
|
|
317
|
+
} else if (socks.includes(mine)) {
|
|
318
|
+
console.log('* = this project and environment');
|
|
319
|
+
}
|
|
320
|
+
return 0;
|
|
321
|
+
}
|
|
322
|
+
console.error(`cdpilot daemon: unknown subcommand '${what}' (start | stop | status)`);
|
|
323
|
+
return 2;
|
|
324
|
+
}
|
|
22
325
|
|
|
23
326
|
// CDPILOT_HOME resolution — must match src/cdpilot.py exactly:
|
|
24
327
|
// CDPILOT_HOME = os.environ.get("CDPILOT_HOME") or os.path.expanduser("~/.cdpilot")
|
|
@@ -86,6 +389,8 @@ function findBrowser() {
|
|
|
86
389
|
// ── Python Detection ──
|
|
87
390
|
|
|
88
391
|
function findPython() {
|
|
392
|
+
const cached = readPythonCache();
|
|
393
|
+
if (cached) return cached;
|
|
89
394
|
// cdpilot.py uses PEP 604 unions (dict | None) → requires Python 3.10+.
|
|
90
395
|
// Prefer version-specific + Homebrew interpreters so a stale default python3
|
|
91
396
|
// (e.g. macOS 3.9) is skipped instead of selected and crashing at runtime.
|
|
@@ -100,6 +405,7 @@ function findPython() {
|
|
|
100
405
|
const ver = execSync(`${cmd} --version 2>&1`, { stdio: 'pipe' }).toString().trim();
|
|
101
406
|
const match = ver.match(/(\d+)\.(\d+)/);
|
|
102
407
|
if (match && (parseInt(match[1]) > 3 || (parseInt(match[1]) === 3 && parseInt(match[2]) >= 10))) {
|
|
408
|
+
writePythonCache(cmd);
|
|
103
409
|
return cmd;
|
|
104
410
|
}
|
|
105
411
|
} catch {}
|
|
@@ -374,7 +680,7 @@ function botAuthLabel(port) {
|
|
|
374
680
|
if (!signer) return fs.existsSync(path.join(home, 'bot-auth', 'config.json')) ? 'bot-auth: off' : null;
|
|
375
681
|
const python = findPython();
|
|
376
682
|
if (!python) return 'bot-auth: off';
|
|
377
|
-
const r = spawnSync(python,
|
|
683
|
+
const r = spawnSync(python, pyArgs('--_bot-auth-label'), {
|
|
378
684
|
encoding: 'utf-8', timeout: 20000,
|
|
379
685
|
env: { ...process.env, CDP_PORT: String(port), CDPILOT_LOG: '0' },
|
|
380
686
|
});
|
|
@@ -453,6 +759,12 @@ function showHelp() {
|
|
|
453
759
|
An auto-launched (or MCP-launched) browser closes after 15 min without a
|
|
454
760
|
cdpilot command or page change (CDPILOT_IDLE_CLOSE=<minutes>,
|
|
455
761
|
0 = never); an explicit launch stays open unless asked (below).
|
|
762
|
+
CDPILOT_DAEMON=1 Keep one warm Python process per project and send page
|
|
763
|
+
commands to it over a local socket (browser-free command
|
|
764
|
+
~270 -> ~140 ms, content/eval ~360 -> ~110 ms, M1 Max;
|
|
765
|
+
a fresh process when it is missing or busy; not with
|
|
766
|
+
--timeout; 'daemon status|stop'; idle exit after
|
|
767
|
+
CDPILOT_DAEMON_IDLE seconds, default 1800).
|
|
456
768
|
|
|
457
769
|
SETUP
|
|
458
770
|
setup Auto-detect browser, create isolated profile
|
|
@@ -590,6 +902,10 @@ function showHelp() {
|
|
|
590
902
|
--load-extension, CfT does not. Used instead of Chrome
|
|
591
903
|
while dev extensions are registered. Not for stealth.
|
|
592
904
|
health JSON status: alive, port, tabs, browser, today's crashes
|
|
905
|
+
daemon start|stop|status
|
|
906
|
+
The warm process behind CDPILOT_DAEMON=1: start it ahead
|
|
907
|
+
of time, stop every running one, or list them (pid,
|
|
908
|
+
requests served, uptime, socket).
|
|
593
909
|
|
|
594
910
|
PROJECTS
|
|
595
911
|
projects List all project browser instances
|
|
@@ -821,22 +1137,7 @@ if (cmd === 'setup') {
|
|
|
821
1137
|
process.exit(0);
|
|
822
1138
|
}
|
|
823
1139
|
|
|
824
|
-
|
|
825
|
-
runStatus();
|
|
826
|
-
// Don't exit immediately — let http callback complete
|
|
827
|
-
} else {
|
|
828
|
-
// Pre-flight check on first run or 'launch' command
|
|
829
|
-
if (cmd === 'launch') {
|
|
830
|
-
preflight();
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
// Delegate to Python
|
|
834
|
-
const python = findPython();
|
|
835
|
-
if (!python) {
|
|
836
|
-
console.error('Error: Python 3.10+ required. Install: https://www.python.org/downloads/');
|
|
837
|
-
process.exit(1);
|
|
838
|
-
}
|
|
839
|
-
|
|
1140
|
+
function pythonEnv() {
|
|
840
1141
|
const browser = findBrowser();
|
|
841
1142
|
const config = resolveProjectConfig();
|
|
842
1143
|
|
|
@@ -861,11 +1162,20 @@ if (cmd === 'status') {
|
|
|
861
1162
|
// (Chrome -> Chrome for Testing) on top of it.
|
|
862
1163
|
env.CDPILOT_CHROME_BIN_AUTO = '1';
|
|
863
1164
|
}
|
|
1165
|
+
return env;
|
|
1166
|
+
}
|
|
864
1167
|
|
|
865
|
-
|
|
1168
|
+
function spawnPython(python, args, env) {
|
|
1169
|
+
const child = spawn(python, pyArgs(...args), {
|
|
866
1170
|
stdio: 'inherit',
|
|
867
1171
|
env,
|
|
868
1172
|
});
|
|
1173
|
+
child.on('error', (err) => {
|
|
1174
|
+
// A remembered interpreter that is gone: forget it so the next call probes again.
|
|
1175
|
+
clearPythonCache();
|
|
1176
|
+
console.error(`cdpilot: could not start ${python}: ${err.message}`);
|
|
1177
|
+
process.exit(1);
|
|
1178
|
+
});
|
|
869
1179
|
|
|
870
1180
|
// A child killed by a signal reports code === null. `code || 0` turned that
|
|
871
1181
|
// into success, so an OOM-killed or SIGKILLed run looked fine to scripts
|
|
@@ -878,3 +1188,50 @@ if (cmd === 'status') {
|
|
|
878
1188
|
process.exit(code ?? 0);
|
|
879
1189
|
});
|
|
880
1190
|
}
|
|
1191
|
+
|
|
1192
|
+
if (cmd === 'status') {
|
|
1193
|
+
runStatus();
|
|
1194
|
+
// Don't exit immediately — let http callback complete
|
|
1195
|
+
} else {
|
|
1196
|
+
// Pre-flight check on first run or 'launch' command
|
|
1197
|
+
if (cmd === 'launch') {
|
|
1198
|
+
preflight();
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
// Delegate to Python
|
|
1202
|
+
const python = findPython();
|
|
1203
|
+
if (!python) {
|
|
1204
|
+
console.error('Error: Python 3.10+ required. Install: https://www.python.org/downloads/');
|
|
1205
|
+
process.exit(1);
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
const env = pythonEnv();
|
|
1209
|
+
|
|
1210
|
+
if (cmd === 'daemon') {
|
|
1211
|
+
daemonCommand(args.slice(1), python, env).then((code) => { process.exitCode = code; });
|
|
1212
|
+
} else if (daemonEligible(cmd, args)) {
|
|
1213
|
+
const sockPath = daemonSockPath(daemonKey(env, python));
|
|
1214
|
+
// env: the daemon applies the caller's environment to the request (proxy
|
|
1215
|
+
// and solver keys, PATH, TZ … are read when a command runs, not at import).
|
|
1216
|
+
const payload = { argv: args, cwd: process.cwd(), env };
|
|
1217
|
+
const stdin = daemonStdin(cmd, args);
|
|
1218
|
+
if (stdin !== undefined) payload.stdin = stdin;
|
|
1219
|
+
daemonRequest(sockPath, payload, (m) => {
|
|
1220
|
+
if (m.o !== undefined) process.stdout.write(m.o);
|
|
1221
|
+
if (m.e !== undefined) process.stderr.write(m.e);
|
|
1222
|
+
}).then((r) => {
|
|
1223
|
+
if (r.handled) {
|
|
1224
|
+
if (r.error) console.error(`cdpilot: ${r.error}`);
|
|
1225
|
+
process.exitCode = r.code;
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
// No daemon (yet): start one for the next call; this call runs fresh.
|
|
1229
|
+
if (['ENOENT', 'ECONNREFUSED', 'version', 'closed', 'stale'].includes(r.reason)) {
|
|
1230
|
+
startDaemon(sockPath, python, env);
|
|
1231
|
+
}
|
|
1232
|
+
spawnPython(python, args, env);
|
|
1233
|
+
});
|
|
1234
|
+
} else {
|
|
1235
|
+
spawnPython(python, args, env);
|
|
1236
|
+
}
|
|
1237
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cdpilot",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.6",
|
|
4
4
|
"mcpName": "io.github.mehmetnadir/cdpilot",
|
|
5
5
|
"description": "Zero-dependency browser automation CLI. Raw CDP, 70+ commands, friction ladder, 3-tier stealth, CAPTCHA + press-and-hold solvers, proxy pools, MCP server. No Puppeteer/Playwright/Selenium.",
|
|
6
6
|
"bin": {
|