@jackwener/opencli 1.2.6 → 1.3.1

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.
@@ -145,3 +145,37 @@ export function networkRequestsJs(includeStatic: boolean): string {
145
145
  })()
146
146
  `;
147
147
  }
148
+
149
+ /**
150
+ * Generate JS to wait until the DOM stabilizes (no mutations for `quietMs`),
151
+ * with a hard cap at `maxMs`. Uses MutationObserver in the browser.
152
+ *
153
+ * Returns as soon as the page stops changing, avoiding unnecessary fixed waits.
154
+ * If document.body is not available, falls back to a fixed sleep of maxMs.
155
+ */
156
+ export function waitForDomStableJs(maxMs: number, quietMs: number): string {
157
+ return `
158
+ new Promise(resolve => {
159
+ if (!document.body) {
160
+ setTimeout(() => resolve('nobody'), ${maxMs});
161
+ return;
162
+ }
163
+ let timer = null;
164
+ let cap = null;
165
+ const done = (reason) => {
166
+ clearTimeout(timer);
167
+ clearTimeout(cap);
168
+ obs.disconnect();
169
+ resolve(reason);
170
+ };
171
+ const resetQuiet = () => {
172
+ clearTimeout(timer);
173
+ timer = setTimeout(() => done('quiet'), ${quietMs});
174
+ };
175
+ const obs = new MutationObserver(resetQuiet);
176
+ obs.observe(document.body, { childList: true, subtree: true, attributes: true });
177
+ resetQuiet();
178
+ cap = setTimeout(() => done('capped'), ${maxMs});
179
+ })
180
+ `;
181
+ }
@@ -23,6 +23,7 @@ import {
23
23
  scrollJs,
24
24
  autoScrollJs,
25
25
  networkRequestsJs,
26
+ waitForDomStableJs,
26
27
  } from './dom-helpers.js';
27
28
 
28
29
  /**
@@ -53,11 +54,15 @@ export class Page implements IPage {
53
54
  if (result?.tabId) {
54
55
  this._tabId = result.tabId;
55
56
  }
56
- // Post-load settle: the extension already waits for tab.status === 'complete',
57
- // but SPA frameworks (React/Vue) need extra time to render after DOM load.
57
+ // Smart settle: use DOM stability detection instead of fixed sleep.
58
+ // settleMs is now a timeout cap (default 1000ms), not a fixed wait.
58
59
  if (options?.waitUntil !== 'none') {
59
- const settleMs = options?.settleMs ?? 1000;
60
- await new Promise(resolve => setTimeout(resolve, settleMs));
60
+ const maxMs = options?.settleMs ?? 1000;
61
+ await sendCommand('exec', {
62
+ code: waitForDomStableJs(maxMs, Math.min(500, maxMs)),
63
+ ...this._workspaceOpt(),
64
+ ...this._tabOpt(),
65
+ });
61
66
  }
62
67
  }
63
68
 
package/src/cli.ts CHANGED
@@ -202,12 +202,12 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
202
202
  console.log(renderCascadeResult(result));
203
203
  });
204
204
 
205
- // ── Built-in: doctor / setup / completion ─────────────────────────────────
205
+ // ── Built-in: doctor / completion ──────────────────────────────────────────
206
206
 
207
207
  program
208
208
  .command('doctor')
209
209
  .description('Diagnose opencli browser bridge connectivity')
210
- .option('--live', 'Test browser connectivity (requires Chrome running)', false)
210
+ .option('--no-live', 'Skip live browser connectivity test')
211
211
  .option('--sessions', 'Show active automation sessions', false)
212
212
  .action(async (opts) => {
213
213
  const { runBrowserDoctor, renderBrowserDoctorReport } = await import('./doctor.js');
@@ -215,14 +215,6 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
215
215
  console.log(renderBrowserDoctorReport(report));
216
216
  });
217
217
 
218
- program
219
- .command('setup')
220
- .description('Interactive setup: verify browser bridge connectivity')
221
- .action(async () => {
222
- const { runSetup } = await import('./setup.js');
223
- await runSetup({ cliVersion: PKG_VERSION });
224
- });
225
-
226
218
  program
227
219
  .command('completion')
228
220
  .description('Output shell completion script')
package/src/daemon.ts CHANGED
@@ -5,6 +5,14 @@
5
5
  * CLI → HTTP POST /command → daemon → WebSocket → Extension
6
6
  * Extension → WebSocket result → daemon → HTTP response → CLI
7
7
  *
8
+ * Security (defense-in-depth against browser-based CSRF):
9
+ * 1. Origin check — reject HTTP/WS from non chrome-extension:// origins
10
+ * 2. Custom header — require X-OpenCLI header (browsers can't send it
11
+ * without CORS preflight, which we deny)
12
+ * 3. No CORS headers — responses never include Access-Control-Allow-Origin
13
+ * 4. Body size limit — 1 MB max to prevent OOM
14
+ * 5. WebSocket verifyClient — reject upgrade before connection is established
15
+ *
8
16
  * Lifecycle:
9
17
  * - Auto-spawned by opencli on first browser command
10
18
  * - Auto-exits after 5 minutes of idle
@@ -49,25 +57,56 @@ function resetIdleTimer(): void {
49
57
 
50
58
  // ─── HTTP Server ─────────────────────────────────────────────────────
51
59
 
60
+ const MAX_BODY = 1024 * 1024; // 1 MB — commands are tiny; this prevents OOM
61
+
52
62
  function readBody(req: IncomingMessage): Promise<string> {
53
63
  return new Promise((resolve, reject) => {
54
64
  const chunks: Buffer[] = [];
55
- req.on('data', (c: Buffer) => chunks.push(c));
65
+ let size = 0;
66
+ req.on('data', (c: Buffer) => {
67
+ size += c.length;
68
+ if (size > MAX_BODY) { req.destroy(); reject(new Error('Body too large')); return; }
69
+ chunks.push(c);
70
+ });
56
71
  req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
57
72
  req.on('error', reject);
58
73
  });
59
74
  }
60
75
 
61
76
  function jsonResponse(res: ServerResponse, status: number, data: unknown): void {
62
- res.writeHead(status, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
77
+ res.writeHead(status, { 'Content-Type': 'application/json' });
63
78
  res.end(JSON.stringify(data));
64
79
  }
65
80
 
66
81
  async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
67
- res.setHeader('Access-Control-Allow-Origin', '*');
68
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
69
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
70
- if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
82
+ // ─── Security: Origin & custom-header check ──────────────────────
83
+ // Block browser-based CSRF: browsers always send an Origin header on
84
+ // cross-origin requests. Node.js CLI fetch does NOT send Origin, so
85
+ // legitimate CLI requests pass through. Chrome Extension connects via
86
+ // WebSocket (which bypasses this HTTP handler entirely).
87
+ const origin = req.headers['origin'] as string | undefined;
88
+ if (origin && !origin.startsWith('chrome-extension://')) {
89
+ jsonResponse(res, 403, { ok: false, error: 'Forbidden: cross-origin request blocked' });
90
+ return;
91
+ }
92
+
93
+ // CORS: do NOT send Access-Control-Allow-Origin for normal requests.
94
+ // Only handle preflight so browsers get a definitive "no" answer.
95
+ if (req.method === 'OPTIONS') {
96
+ // No ACAO header → browser will block the actual request.
97
+ res.writeHead(204);
98
+ res.end();
99
+ return;
100
+ }
101
+
102
+ // Require custom header on all HTTP requests. Browsers cannot attach
103
+ // custom headers in "simple" requests, and our preflight returns no
104
+ // Access-Control-Allow-Headers, so scripted fetch() from web pages is
105
+ // blocked even if Origin check is somehow bypassed.
106
+ if (!req.headers['x-opencli']) {
107
+ jsonResponse(res, 403, { ok: false, error: 'Forbidden: missing X-OpenCLI header' });
108
+ return;
109
+ }
71
110
 
72
111
  const url = req.url ?? '/';
73
112
  const pathname = url.split('?')[0];
@@ -136,7 +175,18 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise
136
175
  // ─── WebSocket for Extension ─────────────────────────────────────────
137
176
 
138
177
  const httpServer = createServer((req, res) => { handleRequest(req, res).catch(() => { res.writeHead(500); res.end(); }); });
139
- const wss = new WebSocketServer({ server: httpServer, path: '/ext' });
178
+ const wss = new WebSocketServer({
179
+ server: httpServer,
180
+ path: '/ext',
181
+ verifyClient: ({ req }: { req: IncomingMessage }) => {
182
+ // Block browser-originated WebSocket connections. Browsers don't
183
+ // enforce CORS on WebSocket, so a malicious webpage could connect to
184
+ // ws://localhost:19825/ext and impersonate the Extension. Real Chrome
185
+ // Extensions send origin chrome-extension://<id>.
186
+ const origin = req.headers['origin'] as string | undefined;
187
+ return !origin || origin.startsWith('chrome-extension://');
188
+ },
189
+ });
140
190
 
141
191
  wss.on('connection', (ws: WebSocket) => {
142
192
  console.error('[daemon] Extension connected');
@@ -84,36 +84,28 @@ describe('doctor report rendering', () => {
84
84
  issues: [],
85
85
  }));
86
86
 
87
- expect(text).toContain('[SKIP] Connectivity: not tested (use --live)');
87
+ expect(text).toContain('[SKIP] Connectivity: skipped (--no-live)');
88
88
  });
89
89
 
90
90
  it('reports consistent status when live check auto-starts the daemon', async () => {
91
- // With the reordered flow, checkDaemonStatus is called only ONCE after
92
- // the connectivity check that may auto-start the daemon.
93
- mockCheckDaemonStatus.mockResolvedValueOnce({ running: true, extensionConnected: false });
94
- mockConnect.mockRejectedValueOnce(new Error(
95
- 'Daemon is running but the Browser Extension is not connected.\n' +
96
- 'Please install and enable the opencli Browser Bridge extension in Chrome.',
97
- ));
98
-
99
- const report = await runBrowserDoctor({ live: true });
100
-
101
- // Status reflects the post-connectivity state (daemon running)
102
- expect(report.daemonRunning).toBe(true);
91
+ // checkDaemonStatus is called twice: once for auto-start check, once for final status.
92
+ // First call: daemon not running (triggers auto-start attempt)
93
+ mockCheckDaemonStatus.mockResolvedValueOnce({ running: false, extensionConnected: false });
94
+ // Auto-start attempt via BrowserBridge.connect fails
95
+ mockConnect.mockRejectedValueOnce(new Error('Could not start daemon'));
96
+ // Second call: daemon still not running after failed auto-start
97
+ mockCheckDaemonStatus.mockResolvedValueOnce({ running: false, extensionConnected: false });
98
+
99
+ const report = await runBrowserDoctor({ live: false });
100
+
101
+ // Status reflects daemon not running
102
+ expect(report.daemonRunning).toBe(false);
103
103
  expect(report.extensionConnected).toBe(false);
104
- // checkDaemonStatus should only be called once
105
- expect(mockCheckDaemonStatus).toHaveBeenCalledTimes(1);
106
- // Should NOT report "daemon not running" since it IS running after live check
107
- expect(report.issues).not.toContain(
108
- expect.stringContaining('Daemon is not running'),
109
- );
110
- // Should report extension not connected
111
- expect(report.issues).toEqual(expect.arrayContaining([
112
- expect.stringContaining('Chrome extension is not connected'),
113
- ]));
114
- // Should report connectivity failure
104
+ // checkDaemonStatus called twice (initial + final)
105
+ expect(mockCheckDaemonStatus).toHaveBeenCalledTimes(2);
106
+ // Should report daemon not running
115
107
  expect(report.issues).toEqual(expect.arrayContaining([
116
- expect.stringContaining('Browser connectivity test failed'),
108
+ expect.stringContaining('Daemon is not running'),
117
109
  ]));
118
110
  });
119
111
  });
package/src/doctor.ts CHANGED
@@ -51,7 +51,19 @@ export async function checkConnectivity(opts?: { timeout?: number }): Promise<Co
51
51
  }
52
52
 
53
53
  export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<DoctorReport> {
54
- // Run the live connectivity check first it may auto-start the daemon as a
54
+ // Try to auto-start daemon if it's not running, so we show accurate status.
55
+ let initialStatus = await checkDaemonStatus();
56
+ if (!initialStatus.running) {
57
+ try {
58
+ const mcp = new BrowserBridge();
59
+ await mcp.connect({ timeout: 5 });
60
+ await mcp.close();
61
+ } catch {
62
+ // Auto-start failed; we'll report it below.
63
+ }
64
+ }
65
+
66
+ // Run the live connectivity check — it may also auto-start the daemon as a
55
67
  // side-effect, so we read daemon status only *after* all side-effects settle.
56
68
  let connectivity: ConnectivityResult | undefined;
57
69
  if (opts.live) {
@@ -109,7 +121,7 @@ export function renderBrowserDoctorReport(report: DoctorReport): string {
109
121
  : `failed (${report.connectivity.error ?? 'unknown'})`;
110
122
  lines.push(`${connIcon} Connectivity: ${detail}`);
111
123
  } else {
112
- lines.push(`${chalk.dim('[SKIP]')} Connectivity: not tested (use --live)`);
124
+ lines.push(`${chalk.dim('[SKIP]')} Connectivity: skipped (--no-live)`);
113
125
  }
114
126
 
115
127
  if (report.sessions) {
@@ -1,25 +0,0 @@
1
- name: Release Please
2
-
3
- on:
4
- push:
5
- branches: [main]
6
-
7
- permissions:
8
- contents: write
9
- pull-requests: write
10
-
11
- jobs:
12
- release-please:
13
- runs-on: ubuntu-latest
14
- steps:
15
- - name: Ensure release-please token is configured
16
- run: |
17
- if [ -z "${{ secrets.RELEASE_PLEASE_TOKEN }}" ]; then
18
- echo "RELEASE_PLEASE_TOKEN secret is required so release PRs can trigger downstream CI workflows." >&2
19
- exit 1
20
- fi
21
-
22
- - uses: googleapis/release-please-action@v4
23
- with:
24
- release-type: node
25
- token: ${{ secrets.RELEASE_PLEASE_TOKEN }}
package/dist/setup.d.ts DELETED
@@ -1,10 +0,0 @@
1
- /**
2
- * setup.ts — Interactive browser setup for opencli
3
- *
4
- * Simplified for daemon-based architecture. No more token management.
5
- * Just verifies daemon + extension connectivity.
6
- */
7
- export declare function runSetup(opts?: {
8
- cliVersion?: string;
9
- token?: string;
10
- }): Promise<void>;
package/dist/setup.js DELETED
@@ -1,66 +0,0 @@
1
- /**
2
- * setup.ts — Interactive browser setup for opencli
3
- *
4
- * Simplified for daemon-based architecture. No more token management.
5
- * Just verifies daemon + extension connectivity.
6
- */
7
- import chalk from 'chalk';
8
- import { checkDaemonStatus } from './browser/discover.js';
9
- import { checkConnectivity } from './doctor.js';
10
- import { BrowserBridge } from './browser/index.js';
11
- export async function runSetup(opts = {}) {
12
- console.log();
13
- console.log(chalk.bold(' opencli setup') + chalk.dim(' — browser bridge configuration'));
14
- console.log();
15
- // Step 1: Check daemon
16
- console.log(chalk.dim(' Checking daemon status...'));
17
- const status = await checkDaemonStatus();
18
- if (status.running) {
19
- console.log(` ${chalk.green('✓')} Daemon is running on port 19825`);
20
- }
21
- else {
22
- console.log(` ${chalk.yellow('!')} Daemon is not running`);
23
- console.log(chalk.dim(' The daemon starts automatically when you run a browser command.'));
24
- console.log(chalk.dim(' Starting daemon now...'));
25
- // Try to spawn daemon
26
- const mcp = new BrowserBridge();
27
- try {
28
- await mcp.connect({ timeout: 5 });
29
- await mcp.close();
30
- console.log(` ${chalk.green('✓')} Daemon started successfully`);
31
- }
32
- catch {
33
- console.log(` ${chalk.yellow('!')} Could not start daemon automatically`);
34
- }
35
- }
36
- // Step 2: Check extension
37
- const statusAfter = await checkDaemonStatus();
38
- if (statusAfter.extensionConnected) {
39
- console.log(` ${chalk.green('✓')} Chrome extension connected`);
40
- }
41
- else {
42
- console.log(` ${chalk.red('✗')} Chrome extension not connected`);
43
- console.log();
44
- console.log(chalk.dim(' To install the opencli Browser Bridge extension:'));
45
- console.log(chalk.dim(' 1. Download from GitHub Releases'));
46
- console.log(chalk.dim(' 2. Open chrome://extensions/ → Enable Developer Mode'));
47
- console.log(chalk.dim(' 3. Click "Load unpacked" → select the extension folder'));
48
- console.log(chalk.dim(' 4. Make sure Chrome is running'));
49
- console.log();
50
- return;
51
- }
52
- // Step 3: Test connectivity
53
- console.log();
54
- console.log(chalk.dim(' Testing browser connectivity...'));
55
- const conn = await checkConnectivity({ timeout: 5 });
56
- if (conn.ok) {
57
- console.log(` ${chalk.green('✓')} Browser connected in ${(conn.durationMs / 1000).toFixed(1)}s`);
58
- console.log();
59
- console.log(chalk.green.bold(' ✓ Setup complete! You can now use opencli browser commands.'));
60
- }
61
- else {
62
- console.log(` ${chalk.yellow('!')} Connectivity test failed: ${conn.error ?? 'unknown'}`);
63
- console.log(chalk.dim(` Run ${chalk.bold('opencli doctor --live')} to diagnose.`));
64
- }
65
- console.log();
66
- }
package/src/setup.ts DELETED
@@ -1,69 +0,0 @@
1
- /**
2
- * setup.ts — Interactive browser setup for opencli
3
- *
4
- * Simplified for daemon-based architecture. No more token management.
5
- * Just verifies daemon + extension connectivity.
6
- */
7
-
8
- import chalk from 'chalk';
9
- import { checkDaemonStatus } from './browser/discover.js';
10
- import { checkConnectivity } from './doctor.js';
11
- import { BrowserBridge } from './browser/index.js';
12
-
13
- export async function runSetup(opts: { cliVersion?: string; token?: string } = {}) {
14
- console.log();
15
- console.log(chalk.bold(' opencli setup') + chalk.dim(' — browser bridge configuration'));
16
- console.log();
17
-
18
- // Step 1: Check daemon
19
- console.log(chalk.dim(' Checking daemon status...'));
20
- const status = await checkDaemonStatus();
21
-
22
- if (status.running) {
23
- console.log(` ${chalk.green('✓')} Daemon is running on port 19825`);
24
- } else {
25
- console.log(` ${chalk.yellow('!')} Daemon is not running`);
26
- console.log(chalk.dim(' The daemon starts automatically when you run a browser command.'));
27
- console.log(chalk.dim(' Starting daemon now...'));
28
-
29
- // Try to spawn daemon
30
- const mcp = new BrowserBridge();
31
- try {
32
- await mcp.connect({ timeout: 5 });
33
- await mcp.close();
34
- console.log(` ${chalk.green('✓')} Daemon started successfully`);
35
- } catch {
36
- console.log(` ${chalk.yellow('!')} Could not start daemon automatically`);
37
- }
38
- }
39
-
40
- // Step 2: Check extension
41
- const statusAfter = await checkDaemonStatus();
42
- if (statusAfter.extensionConnected) {
43
- console.log(` ${chalk.green('✓')} Chrome extension connected`);
44
- } else {
45
- console.log(` ${chalk.red('✗')} Chrome extension not connected`);
46
- console.log();
47
- console.log(chalk.dim(' To install the opencli Browser Bridge extension:'));
48
- console.log(chalk.dim(' 1. Download from GitHub Releases'));
49
- console.log(chalk.dim(' 2. Open chrome://extensions/ → Enable Developer Mode'));
50
- console.log(chalk.dim(' 3. Click "Load unpacked" → select the extension folder'));
51
- console.log(chalk.dim(' 4. Make sure Chrome is running'));
52
- console.log();
53
- return;
54
- }
55
-
56
- // Step 3: Test connectivity
57
- console.log();
58
- console.log(chalk.dim(' Testing browser connectivity...'));
59
- const conn = await checkConnectivity({ timeout: 5 });
60
- if (conn.ok) {
61
- console.log(` ${chalk.green('✓')} Browser connected in ${(conn.durationMs / 1000).toFixed(1)}s`);
62
- console.log();
63
- console.log(chalk.green.bold(' ✓ Setup complete! You can now use opencli browser commands.'));
64
- } else {
65
- console.log(` ${chalk.yellow('!')} Connectivity test failed: ${conn.error ?? 'unknown'}`);
66
- console.log(chalk.dim(` Run ${chalk.bold('opencli doctor --live')} to diagnose.`));
67
- }
68
- console.log();
69
- }