@phnx-labs/agents-cli 1.18.3 → 1.18.5
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/CHANGELOG.md +89 -0
- package/README.md +14 -7
- package/dist/commands/browser.js +503 -132
- package/dist/commands/factory.js +13 -1
- package/dist/commands/rules.js +14 -0
- package/dist/commands/secrets.js +66 -11
- package/dist/lib/browser/cdp.js +7 -1
- package/dist/lib/browser/chrome.d.ts +1 -1
- package/dist/lib/browser/chrome.js +52 -26
- package/dist/lib/browser/devices.d.ts +11 -0
- package/dist/lib/browser/devices.js +14 -3
- package/dist/lib/browser/drivers/local.js +29 -2
- package/dist/lib/browser/drivers/ssh.js +82 -7
- package/dist/lib/browser/ipc.js +84 -9
- package/dist/lib/browser/profiles.d.ts +69 -3
- package/dist/lib/browser/profiles.js +184 -20
- package/dist/lib/browser/runtime-state.d.ts +117 -0
- package/dist/lib/browser/runtime-state.js +259 -0
- package/dist/lib/browser/service.d.ts +57 -10
- package/dist/lib/browser/service.js +477 -73
- package/dist/lib/browser/types.d.ts +67 -2
- package/dist/lib/browser/types.js +20 -0
- package/dist/lib/daemon.js +36 -3
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/help.js +30 -3
- package/dist/lib/secrets/bundles.d.ts +20 -0
- package/dist/lib/secrets/bundles.js +56 -0
- package/dist/lib/secrets/index.js +8 -8
- package/dist/lib/types.d.ts +16 -1
- package/dist/lib/version.d.ts +7 -0
- package/dist/lib/version.js +25 -0
- package/package.json +1 -1
|
@@ -1,16 +1,52 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
2
3
|
import * as path from 'path';
|
|
4
|
+
import { execFile } from 'child_process';
|
|
5
|
+
import { promisify } from 'util';
|
|
3
6
|
import { CDPClient, discoverBrowserWsUrl, verifyBrowserIdentity } from './cdp.js';
|
|
4
|
-
import { getProfile, getProfileRuntimeDir, getBrowserRuntimeDir, listProfiles, extractConfiguredPort, } from './profiles.js';
|
|
7
|
+
import { getProfile, getProfileRuntimeDir, getBrowserRuntimeDir, listProfiles, extractConfiguredPort, resolveEndpoint, } from './profiles.js';
|
|
5
8
|
import { killChrome, getRunningChromeInfo, launchBrowser, allocatePort } from './chrome.js';
|
|
6
9
|
import { connectLocal } from './drivers/local.js';
|
|
7
10
|
import { connectSSH } from './drivers/ssh.js';
|
|
8
|
-
import {
|
|
11
|
+
import { clearProfileRuntime } from './runtime-state.js';
|
|
12
|
+
import { generateTaskId, generateShortId, generateTaskName, } from './types.js';
|
|
9
13
|
import { getRefs, resolveRefToCoords } from './refs.js';
|
|
10
14
|
import { clickAtCoords, hoverAtCoords, scrollAtCoords, typeText, pressKey, focusNode } from './input.js';
|
|
11
15
|
import { typeEditorText } from './editor.js';
|
|
12
16
|
import { detectUploadPattern, uploadToDropTarget, uploadToFileInput, uploadViaFileChooser, } from './upload.js';
|
|
13
17
|
import { emit } from '../events.js';
|
|
18
|
+
/**
|
|
19
|
+
* Read width/height from a JPEG buffer by walking SOF markers. Returns null
|
|
20
|
+
* if the buffer doesn't start with the JPEG SOI marker or no SOF segment is
|
|
21
|
+
* found. We use this on every screenshot so the CLI can surface the actual
|
|
22
|
+
* captured pixel dimensions (which differ from viewport size at non-1x DPR).
|
|
23
|
+
*/
|
|
24
|
+
function readPngDimensions(buf) {
|
|
25
|
+
// PNG signature (8 bytes) + IHDR chunk: length (4) + 'IHDR' (4) + width (4) + height (4)
|
|
26
|
+
if (buf.length < 24)
|
|
27
|
+
return null;
|
|
28
|
+
const sig = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
29
|
+
for (let i = 0; i < 8; i++)
|
|
30
|
+
if (buf[i] !== sig[i])
|
|
31
|
+
return null;
|
|
32
|
+
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
|
33
|
+
}
|
|
34
|
+
function readJpegDimensions(buf) {
|
|
35
|
+
if (buf.length < 4 || buf[0] !== 0xff || buf[1] !== 0xd8)
|
|
36
|
+
return null;
|
|
37
|
+
let i = 2;
|
|
38
|
+
while (i + 9 < buf.length) {
|
|
39
|
+
if (buf[i] !== 0xff)
|
|
40
|
+
return null;
|
|
41
|
+
const marker = buf[i + 1];
|
|
42
|
+
// SOF0–SOFn carry dimensions, except DHT (0xC4), JPG (0xC8), DAC (0xCC).
|
|
43
|
+
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
|
44
|
+
return { height: buf.readUInt16BE(i + 5), width: buf.readUInt16BE(i + 7) };
|
|
45
|
+
}
|
|
46
|
+
i += 2 + buf.readUInt16BE(i + 2);
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
14
50
|
/**
|
|
15
51
|
* Parse a `targetFilter` string into its kind + value, or return `null`
|
|
16
52
|
* when the input is missing or malformed. Filter syntax:
|
|
@@ -92,7 +128,81 @@ export function pickWindowTarget(targets, filter) {
|
|
|
92
128
|
return visible;
|
|
93
129
|
return pages[0];
|
|
94
130
|
}
|
|
131
|
+
const execFileP = promisify(execFile);
|
|
132
|
+
/**
|
|
133
|
+
* Parse a `--since`/`--until` value. Accepts ISO-8601 absolute timestamps
|
|
134
|
+
* or relative offsets like `30s`, `5m`, `2h`, `1d`.
|
|
135
|
+
*/
|
|
136
|
+
export function parseSinceUntil(s) {
|
|
137
|
+
const ms = Date.parse(s);
|
|
138
|
+
if (!isNaN(ms))
|
|
139
|
+
return new Date(ms);
|
|
140
|
+
const m = s.match(/^(\d+)([smhd])$/);
|
|
141
|
+
if (!m)
|
|
142
|
+
throw new Error(`Invalid since/until: ${s}`);
|
|
143
|
+
const n = parseInt(m[1], 10);
|
|
144
|
+
const unitMs = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 };
|
|
145
|
+
return new Date(Date.now() - n * unitMs[m[2]]);
|
|
146
|
+
}
|
|
147
|
+
async function execSSH(host, cmd) {
|
|
148
|
+
const { stdout } = await execFileP('ssh', [host, cmd], {
|
|
149
|
+
timeout: 10_000,
|
|
150
|
+
maxBuffer: 10_000_000,
|
|
151
|
+
});
|
|
152
|
+
return stdout;
|
|
153
|
+
}
|
|
154
|
+
export function readNewestMatchingFile(dir, prefix, tailLines) {
|
|
155
|
+
let entries;
|
|
156
|
+
try {
|
|
157
|
+
entries = fs.readdirSync(dir);
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return '';
|
|
161
|
+
}
|
|
162
|
+
const candidates = entries
|
|
163
|
+
.filter((f) => f.startsWith(prefix) && f.endsWith('.jsonl'))
|
|
164
|
+
.map((f) => ({ f, mtime: fs.statSync(path.join(dir, f)).mtimeMs }))
|
|
165
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
166
|
+
if (candidates.length === 0)
|
|
167
|
+
return '';
|
|
168
|
+
const lines = fs
|
|
169
|
+
.readFileSync(path.join(dir, candidates[0].f), 'utf8')
|
|
170
|
+
.split('\n')
|
|
171
|
+
.filter(Boolean);
|
|
172
|
+
return lines.slice(-tailLines).join('\n');
|
|
173
|
+
}
|
|
174
|
+
function expandHome(p) {
|
|
175
|
+
if (p.startsWith('~/'))
|
|
176
|
+
return path.join(os.homedir(), p.slice(2));
|
|
177
|
+
if (p === '~')
|
|
178
|
+
return os.homedir();
|
|
179
|
+
return p;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Probe a cached connection before reuse. A WebSocket can quietly transition
|
|
183
|
+
* to CLOSED without anyone noticing — most commonly when the user kills the
|
|
184
|
+
* browser process by hand. `Browser.getVersion` is the lightest CDP call we
|
|
185
|
+
* can make; if it doesn't round-trip within 1s the connection is dead.
|
|
186
|
+
*/
|
|
187
|
+
async function isConnHealthy(conn, timeoutMs = 1000) {
|
|
188
|
+
if (!conn.cdp.isOpen)
|
|
189
|
+
return false;
|
|
190
|
+
try {
|
|
191
|
+
await Promise.race([
|
|
192
|
+
conn.cdp.send('Browser.getVersion'),
|
|
193
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('healthcheck timeout')), timeoutMs)),
|
|
194
|
+
]);
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
95
201
|
export class BrowserService {
|
|
202
|
+
static SOURCE_PREFIX = {
|
|
203
|
+
'rush-app': 'rush-app-',
|
|
204
|
+
'rush-cli': 'rush-cli-',
|
|
205
|
+
};
|
|
96
206
|
connections = new Map();
|
|
97
207
|
forkingProfiles = new Set();
|
|
98
208
|
// Per-task storage for console, errors, network, downloads
|
|
@@ -106,39 +216,74 @@ export class BrowserService {
|
|
|
106
216
|
if (!profile) {
|
|
107
217
|
throw new Error(`Profile "${profileName}" not found`);
|
|
108
218
|
}
|
|
109
|
-
|
|
219
|
+
// Pick the endpoint preset. Throws with the candidate list if the user
|
|
220
|
+
// passed an unknown name. The composite identifier `<profile>@<endpoint>`
|
|
221
|
+
// is what the connection map + per-profile runtime dirs are keyed on, so
|
|
222
|
+
// a single YAML profile can run at multiple endpoints concurrently.
|
|
223
|
+
const resolved = resolveEndpoint(profile, opts.endpointName);
|
|
224
|
+
const composite = `${profileName}@${resolved.name}`;
|
|
225
|
+
const effectiveProfile = {
|
|
226
|
+
...profile,
|
|
227
|
+
name: composite,
|
|
228
|
+
binary: resolved.binary,
|
|
229
|
+
targetFilter: resolved.targetFilter,
|
|
230
|
+
};
|
|
231
|
+
let taskName;
|
|
232
|
+
if (opts.taskName) {
|
|
233
|
+
if (this.hasTaskNamed(opts.taskName)) {
|
|
234
|
+
throw new Error(`Task "${opts.taskName}" already exists. Pick a different --task name or stop the existing one first.`);
|
|
235
|
+
}
|
|
236
|
+
taskName = opts.taskName;
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
taskName = this.generateUniqueTaskName();
|
|
240
|
+
}
|
|
110
241
|
const taskId = generateTaskId();
|
|
111
|
-
let conn = this.connections.get(
|
|
112
|
-
let effectiveProfileName =
|
|
242
|
+
let conn = this.connections.get(composite);
|
|
243
|
+
let effectiveProfileName = composite;
|
|
244
|
+
// If we have a cached connection, confirm it's still usable before any
|
|
245
|
+
// caller relies on it. A browser killed externally (Cmd-Q, crash, or a
|
|
246
|
+
// user clearing the profile by hand) leaves a closed WebSocket here.
|
|
247
|
+
// Without this check the next `cdp.send` throws "CDP connection not
|
|
248
|
+
// open" and the user has no way to recover short of killing the daemon.
|
|
249
|
+
if (conn && !(await isConnHealthy(conn))) {
|
|
250
|
+
try {
|
|
251
|
+
conn.cdp.close();
|
|
252
|
+
}
|
|
253
|
+
catch { /* already closed */ }
|
|
254
|
+
conn.cleanup?.();
|
|
255
|
+
this.connections.delete(composite);
|
|
256
|
+
conn = undefined;
|
|
257
|
+
}
|
|
113
258
|
if (conn && conn.electron && conn.tasks.size > 0) {
|
|
114
|
-
if (this.forkingProfiles.has(
|
|
115
|
-
while (this.forkingProfiles.has(
|
|
259
|
+
if (this.forkingProfiles.has(composite)) {
|
|
260
|
+
while (this.forkingProfiles.has(composite)) {
|
|
116
261
|
await new Promise((r) => setTimeout(r, 50));
|
|
117
262
|
}
|
|
118
|
-
const existingFork = this.findAvailableFork(
|
|
263
|
+
const existingFork = this.findAvailableFork(composite);
|
|
119
264
|
if (existingFork) {
|
|
120
265
|
conn = existingFork.conn;
|
|
121
266
|
effectiveProfileName = existingFork.name;
|
|
122
267
|
}
|
|
123
268
|
else {
|
|
124
|
-
throw new Error(`Fork in progress but no available fork found for "${
|
|
269
|
+
throw new Error(`Fork in progress but no available fork found for "${composite}"`);
|
|
125
270
|
}
|
|
126
271
|
}
|
|
127
272
|
else {
|
|
128
|
-
this.forkingProfiles.add(
|
|
273
|
+
this.forkingProfiles.add(composite);
|
|
129
274
|
try {
|
|
130
|
-
const { forkName, connection } = await this.forkElectronProfile(
|
|
275
|
+
const { forkName, connection } = await this.forkElectronProfile(effectiveProfile);
|
|
131
276
|
conn = connection;
|
|
132
277
|
effectiveProfileName = forkName;
|
|
133
278
|
}
|
|
134
279
|
finally {
|
|
135
|
-
this.forkingProfiles.delete(
|
|
280
|
+
this.forkingProfiles.delete(composite);
|
|
136
281
|
}
|
|
137
282
|
}
|
|
138
283
|
}
|
|
139
284
|
else if (!conn) {
|
|
140
|
-
conn = await this.connectProfile(
|
|
141
|
-
this.connections.set(
|
|
285
|
+
conn = await this.connectProfile(effectiveProfile, resolved.target);
|
|
286
|
+
this.connections.set(composite, conn);
|
|
142
287
|
}
|
|
143
288
|
const task = {
|
|
144
289
|
id: taskId,
|
|
@@ -178,25 +323,7 @@ export class BrowserService {
|
|
|
178
323
|
const result = await this.navigate(taskName, opts.url, effectiveProfileName);
|
|
179
324
|
tabId = result.tabId;
|
|
180
325
|
}
|
|
181
|
-
return { task: taskId, name: taskName, tabId };
|
|
182
|
-
}
|
|
183
|
-
/**
|
|
184
|
-
* Launch (or attach to) the profile's browser without creating a task. Used by
|
|
185
|
-
* `agents browser profiles launch <name>` so users can warm up the browser —
|
|
186
|
-
* including the first-run onboarding flow — before any automation starts.
|
|
187
|
-
*/
|
|
188
|
-
async launchProfile(profileName) {
|
|
189
|
-
const profile = await getProfile(profileName);
|
|
190
|
-
if (!profile) {
|
|
191
|
-
throw new Error(`Profile "${profileName}" not found`);
|
|
192
|
-
}
|
|
193
|
-
let conn = this.connections.get(profileName);
|
|
194
|
-
if (!conn) {
|
|
195
|
-
conn = await this.connectProfile(profile);
|
|
196
|
-
this.connections.set(profileName, conn);
|
|
197
|
-
}
|
|
198
|
-
emit('browser.launch', { profile: profileName, task: '', pid: conn.pid });
|
|
199
|
-
return { port: conn.port, pid: conn.pid };
|
|
326
|
+
return { task: taskId, name: taskName, tabId, profile: effectiveProfileName };
|
|
200
327
|
}
|
|
201
328
|
async stop(taskName) {
|
|
202
329
|
for (const [profileName, conn] of this.connections) {
|
|
@@ -239,6 +366,7 @@ export class BrowserService {
|
|
|
239
366
|
if (conn.forkedFrom && conn.tasks.size === 0) {
|
|
240
367
|
conn.cdp.close();
|
|
241
368
|
killChrome(conn.pid);
|
|
369
|
+
conn.cleanup?.();
|
|
242
370
|
this.connections.delete(profileName);
|
|
243
371
|
}
|
|
244
372
|
return { ok: true, profile: profileName };
|
|
@@ -254,6 +382,7 @@ export class BrowserService {
|
|
|
254
382
|
if (conn) {
|
|
255
383
|
conn.cdp.close();
|
|
256
384
|
killChrome(conn.pid);
|
|
385
|
+
conn.cleanup?.();
|
|
257
386
|
this.connections.delete(profileName);
|
|
258
387
|
}
|
|
259
388
|
const runtimeDir = getProfileRuntimeDir(profileName);
|
|
@@ -455,7 +584,7 @@ export class BrowserService {
|
|
|
455
584
|
}
|
|
456
585
|
return result.result.value;
|
|
457
586
|
}
|
|
458
|
-
async screenshot(taskId, tabHint, outputPath) {
|
|
587
|
+
async screenshot(taskId, tabHint, outputPath, quality = 'compressed') {
|
|
459
588
|
const { conn, task } = await this.findTask(taskId);
|
|
460
589
|
const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
|
|
461
590
|
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
@@ -464,22 +593,201 @@ export class BrowserService {
|
|
|
464
593
|
throw new Error(`Tab ${shortId} not found`);
|
|
465
594
|
}
|
|
466
595
|
const sessionId = await this.getSessionId(conn, target.targetId);
|
|
467
|
-
|
|
468
|
-
let
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
596
|
+
let buffer;
|
|
597
|
+
let extension;
|
|
598
|
+
if (quality === 'raw') {
|
|
599
|
+
// Pixel-faithful PNG, no downscale. For archived QA evidence where
|
|
600
|
+
// lossy JPEG would hide rendering bugs. Files run 0.5–3 MB.
|
|
601
|
+
const { data } = (await conn.cdp.send('Page.captureScreenshot', { format: 'png' }, sessionId));
|
|
602
|
+
buffer = Buffer.from(data, 'base64');
|
|
603
|
+
extension = 'png';
|
|
604
|
+
}
|
|
605
|
+
else {
|
|
606
|
+
// Default: JPEG quality 70, then iteratively downscale to keep the
|
|
607
|
+
// file under 100 KB so chat-injected screenshots stay token-cheap.
|
|
608
|
+
const { data } = (await conn.cdp.send('Page.captureScreenshot', { format: 'jpeg', quality: 70 }, sessionId));
|
|
609
|
+
buffer = Buffer.from(data, 'base64');
|
|
610
|
+
const MAX_SIZE = 100 * 1024;
|
|
611
|
+
if (buffer.length > MAX_SIZE) {
|
|
612
|
+
let q = 50;
|
|
613
|
+
while (buffer.length > MAX_SIZE && q > 10) {
|
|
614
|
+
const { data: resized } = (await conn.cdp.send('Page.captureScreenshot', { format: 'jpeg', quality: q }, sessionId));
|
|
615
|
+
buffer = Buffer.from(resized, 'base64');
|
|
616
|
+
q -= 10;
|
|
617
|
+
}
|
|
476
618
|
}
|
|
619
|
+
extension = 'jpg';
|
|
477
620
|
}
|
|
478
621
|
const sessionsDir = path.join(getBrowserRuntimeDir(), 'sessions', task.name);
|
|
479
|
-
const finalPath = outputPath || path.join(sessionsDir, `${Date.now()}
|
|
622
|
+
const finalPath = outputPath || path.join(sessionsDir, `${Date.now()}.${extension}`);
|
|
480
623
|
await fs.promises.mkdir(path.dirname(finalPath), { recursive: true });
|
|
481
624
|
await fs.promises.writeFile(finalPath, buffer);
|
|
482
|
-
|
|
625
|
+
const dims = (extension === 'png' ? readPngDimensions(buffer) : readJpegDimensions(buffer)) ??
|
|
626
|
+
{ width: 0, height: 0 };
|
|
627
|
+
return { path: finalPath, bytes: buffer.length, width: dims.width, height: dims.height };
|
|
628
|
+
}
|
|
629
|
+
// ─── Recording ──────────────────────────────────────────────────────────────
|
|
630
|
+
//
|
|
631
|
+
// CDP `Page.startScreencast` emits a JPEG frame per `everyNthFrame`. We pipe
|
|
632
|
+
// those frames into ffmpeg's stdin (image2pipe) and encode to a webm/vp9 file
|
|
633
|
+
// under `sessions/<task>/recordings/`. A background watcher enforces the
|
|
634
|
+
// duration + size caps so a forgotten recording can't fill the disk.
|
|
635
|
+
recordings = new Map();
|
|
636
|
+
async recordStart(taskId, tabHint, opts = {}) {
|
|
637
|
+
if (this.recordings.has(taskId)) {
|
|
638
|
+
throw new Error(`Task "${taskId}" is already recording. Call record stop first.`);
|
|
639
|
+
}
|
|
640
|
+
const { conn, task } = await this.findTask(taskId);
|
|
641
|
+
const shortId = tabHint ? await this.resolveTabHint(conn, task, tabHint) : this.resolveCurrentTab(task);
|
|
642
|
+
const cdpTargetId = this.getCdpTargetId(task, shortId);
|
|
643
|
+
const target = await this.getTarget(conn, cdpTargetId);
|
|
644
|
+
if (!target)
|
|
645
|
+
throw new Error(`Tab ${shortId} not found`);
|
|
646
|
+
const sessionId = await this.getSessionId(conn, target.targetId);
|
|
647
|
+
const fps = opts.fps ?? 5;
|
|
648
|
+
const durationSec = opts.duration ?? 60;
|
|
649
|
+
const maxMb = opts.maxMb ?? 25;
|
|
650
|
+
if (fps < 1 || fps > 30)
|
|
651
|
+
throw new Error('--fps must be between 1 and 30');
|
|
652
|
+
if (durationSec < 1 || durationSec > 3600)
|
|
653
|
+
throw new Error('--duration must be between 1 and 3600 seconds');
|
|
654
|
+
if (maxMb < 1 || maxMb > 500)
|
|
655
|
+
throw new Error('--max-mb must be between 1 and 500');
|
|
656
|
+
const recordingsDir = path.join(getBrowserRuntimeDir(), 'sessions', task.name, 'recordings');
|
|
657
|
+
await fs.promises.mkdir(recordingsDir, { recursive: true });
|
|
658
|
+
const outputPath = path.join(recordingsDir, `${Date.now()}.webm`);
|
|
659
|
+
// Resolve ffmpeg lazily so non-recording paths don't pay the import cost.
|
|
660
|
+
const { spawn } = await import('child_process');
|
|
661
|
+
const ffmpeg = spawn('ffmpeg', [
|
|
662
|
+
'-loglevel', 'error',
|
|
663
|
+
'-f', 'image2pipe',
|
|
664
|
+
'-framerate', String(fps),
|
|
665
|
+
'-i', '-',
|
|
666
|
+
'-c:v', 'libvpx-vp9',
|
|
667
|
+
'-b:v', '1M',
|
|
668
|
+
'-pix_fmt', 'yuv420p',
|
|
669
|
+
'-y',
|
|
670
|
+
outputPath,
|
|
671
|
+
], { stdio: ['pipe', 'ignore', 'pipe'] });
|
|
672
|
+
// Wait for the spawn to confirm (or fail) before we wire CDP frames into a
|
|
673
|
+
// dead pipe. ENOENT from a missing ffmpeg surfaces here as a real error
|
|
674
|
+
// instead of a silently empty .webm.
|
|
675
|
+
await new Promise((resolve, reject) => {
|
|
676
|
+
const onError = (err) => {
|
|
677
|
+
ffmpeg.off('spawn', onSpawn);
|
|
678
|
+
if (err.code === 'ENOENT') {
|
|
679
|
+
reject(new Error('ffmpeg not found on PATH — install via `brew install ffmpeg`'));
|
|
680
|
+
}
|
|
681
|
+
else {
|
|
682
|
+
reject(err);
|
|
683
|
+
}
|
|
684
|
+
};
|
|
685
|
+
const onSpawn = () => {
|
|
686
|
+
ffmpeg.off('error', onError);
|
|
687
|
+
resolve();
|
|
688
|
+
};
|
|
689
|
+
ffmpeg.once('error', onError);
|
|
690
|
+
ffmpeg.once('spawn', onSpawn);
|
|
691
|
+
});
|
|
692
|
+
// Surface ffmpeg's own diagnostics (encoder error, etc.) in case stderr
|
|
693
|
+
// arrives after spawn.
|
|
694
|
+
ffmpeg.stderr?.on('data', () => { });
|
|
695
|
+
ffmpeg.on('error', () => { });
|
|
696
|
+
// 30 fps is CDP's screencast cap; everyNthFrame = round(30/fps).
|
|
697
|
+
const everyNthFrame = Math.max(1, Math.round(30 / fps));
|
|
698
|
+
await conn.cdp.send('Page.startScreencast', { format: 'jpeg', quality: 60, everyNthFrame }, sessionId);
|
|
699
|
+
const frameHandler = (params) => {
|
|
700
|
+
const p = params;
|
|
701
|
+
try {
|
|
702
|
+
ffmpeg.stdin?.write(Buffer.from(p.data, 'base64'));
|
|
703
|
+
}
|
|
704
|
+
catch {
|
|
705
|
+
// ffmpeg exited; ignore writes
|
|
706
|
+
}
|
|
707
|
+
// Must ack every frame or CDP stops sending.
|
|
708
|
+
conn.cdp.send('Page.screencastFrameAck', { sessionId: p.sessionId }, sessionId).catch(() => { });
|
|
709
|
+
};
|
|
710
|
+
conn.cdp.on('Page.screencastFrame', frameHandler);
|
|
711
|
+
const durationMs = durationSec * 1000;
|
|
712
|
+
const maxBytes = maxMb * 1024 * 1024;
|
|
713
|
+
const state = {
|
|
714
|
+
outputPath,
|
|
715
|
+
startedAt: Date.now(),
|
|
716
|
+
fps,
|
|
717
|
+
durationMs,
|
|
718
|
+
maxBytes,
|
|
719
|
+
ffmpeg,
|
|
720
|
+
sessionId,
|
|
721
|
+
conn,
|
|
722
|
+
frameHandler,
|
|
723
|
+
durationTimer: setTimeout(() => {
|
|
724
|
+
this.recordStop(taskId, 'duration-cap').catch(() => { });
|
|
725
|
+
}, durationMs),
|
|
726
|
+
sizeCheckInterval: setInterval(async () => {
|
|
727
|
+
try {
|
|
728
|
+
const st = await fs.promises.stat(outputPath);
|
|
729
|
+
if (st.size >= maxBytes) {
|
|
730
|
+
await this.recordStop(taskId, 'size-cap');
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
catch {
|
|
734
|
+
// File may not exist yet
|
|
735
|
+
}
|
|
736
|
+
}, 1000),
|
|
737
|
+
};
|
|
738
|
+
this.recordings.set(taskId, state);
|
|
739
|
+
return { path: outputPath, fps, durationCapSec: durationSec, maxMb };
|
|
740
|
+
}
|
|
741
|
+
async recordStop(taskId, reason = 'manual') {
|
|
742
|
+
const rec = this.recordings.get(taskId);
|
|
743
|
+
if (!rec) {
|
|
744
|
+
throw new Error(`Task "${taskId}" is not currently recording`);
|
|
745
|
+
}
|
|
746
|
+
if (rec.stopReason) {
|
|
747
|
+
// Already stopping (e.g. size-cap fired while user also called stop).
|
|
748
|
+
// Wait for in-flight finalize.
|
|
749
|
+
while (this.recordings.has(taskId)) {
|
|
750
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
rec.stopReason = reason;
|
|
754
|
+
clearTimeout(rec.durationTimer);
|
|
755
|
+
clearInterval(rec.sizeCheckInterval);
|
|
756
|
+
rec.conn.cdp.off('Page.screencastFrame', rec.frameHandler);
|
|
757
|
+
try {
|
|
758
|
+
await rec.conn.cdp.send('Page.stopScreencast', {}, rec.sessionId);
|
|
759
|
+
}
|
|
760
|
+
catch {
|
|
761
|
+
// session may already be gone
|
|
762
|
+
}
|
|
763
|
+
// Close ffmpeg stdin so it flushes the output file cleanly.
|
|
764
|
+
await new Promise((resolve) => {
|
|
765
|
+
rec.ffmpeg.on('exit', () => resolve());
|
|
766
|
+
try {
|
|
767
|
+
rec.ffmpeg.stdin?.end();
|
|
768
|
+
}
|
|
769
|
+
catch {
|
|
770
|
+
resolve();
|
|
771
|
+
}
|
|
772
|
+
setTimeout(resolve, 5000); // Hard timeout if ffmpeg hangs
|
|
773
|
+
});
|
|
774
|
+
let bytes = 0;
|
|
775
|
+
try {
|
|
776
|
+
const st = await fs.promises.stat(rec.outputPath);
|
|
777
|
+
bytes = st.size;
|
|
778
|
+
}
|
|
779
|
+
catch {
|
|
780
|
+
// ffmpeg may have failed; file missing
|
|
781
|
+
}
|
|
782
|
+
const durationMs = Date.now() - rec.startedAt;
|
|
783
|
+
this.recordings.delete(taskId);
|
|
784
|
+
return { path: rec.outputPath, bytes, durationMs, reason };
|
|
785
|
+
}
|
|
786
|
+
async recordStatus(taskId) {
|
|
787
|
+
const rec = this.recordings.get(taskId);
|
|
788
|
+
if (!rec)
|
|
789
|
+
return { recording: false };
|
|
790
|
+
return { recording: true, path: rec.outputPath, elapsedMs: Date.now() - rec.startedAt };
|
|
483
791
|
}
|
|
484
792
|
refsCache = new Map();
|
|
485
793
|
async refs(taskId, tabHint, opts = {}) {
|
|
@@ -868,6 +1176,46 @@ export class BrowserService {
|
|
|
868
1176
|
}
|
|
869
1177
|
throw new Error(`No response matching "${urlPattern}" within ${timeout}ms`);
|
|
870
1178
|
}
|
|
1179
|
+
// ─── App Logs (source-side JSONL) ───────────────────────────────────────────
|
|
1180
|
+
async getAppLogs(taskId, opts) {
|
|
1181
|
+
const { task } = await this.findTask(taskId);
|
|
1182
|
+
const baseProfileName = task.profile.split('@')[0];
|
|
1183
|
+
const profile = await getProfile(baseProfileName);
|
|
1184
|
+
if (!profile?.logDir) {
|
|
1185
|
+
throw new Error(`Profile '${task.profile}' has no logDir set`);
|
|
1186
|
+
}
|
|
1187
|
+
const logDir = expandHome(profile.logDir);
|
|
1188
|
+
const sources = opts.source ? [opts.source] : ['rush-app', 'rush-cli'];
|
|
1189
|
+
const since = opts.since ? parseSinceUntil(opts.since) : null;
|
|
1190
|
+
const until = opts.until ? parseSinceUntil(opts.until) : null;
|
|
1191
|
+
const tailN = since ? 100_000 : (opts.lines ?? 200);
|
|
1192
|
+
const raws = await Promise.all(sources.map(async (src) => {
|
|
1193
|
+
const prefix = BrowserService.SOURCE_PREFIX[src];
|
|
1194
|
+
if (!prefix)
|
|
1195
|
+
return '';
|
|
1196
|
+
if (profile.logHost) {
|
|
1197
|
+
return execSSH(profile.logHost, `ls -1t ${logDir}/${prefix}*.jsonl 2>/dev/null | head -1 | xargs -r tail -n ${tailN}`);
|
|
1198
|
+
}
|
|
1199
|
+
return readNewestMatchingFile(logDir, prefix, tailN);
|
|
1200
|
+
}));
|
|
1201
|
+
const entries = raws
|
|
1202
|
+
.flatMap((r) => r.split('\n').filter(Boolean))
|
|
1203
|
+
.map((line) => {
|
|
1204
|
+
try {
|
|
1205
|
+
return JSON.parse(line);
|
|
1206
|
+
}
|
|
1207
|
+
catch {
|
|
1208
|
+
return { raw: line };
|
|
1209
|
+
}
|
|
1210
|
+
})
|
|
1211
|
+
.filter((e) => !opts.level || e.level === opts.level)
|
|
1212
|
+
.filter((e) => !opts.message || e.message === opts.message)
|
|
1213
|
+
.filter((e) => !opts.filter || JSON.stringify(e).includes(opts.filter))
|
|
1214
|
+
.filter((e) => !since || (e.timestamp && new Date(e.timestamp) >= since))
|
|
1215
|
+
.filter((e) => !until || (e.timestamp && new Date(e.timestamp) <= until))
|
|
1216
|
+
.sort((a, b) => new Date(a.timestamp ?? 0).getTime() - new Date(b.timestamp ?? 0).getTime());
|
|
1217
|
+
return since ? entries : entries.slice(-(opts.lines ?? 200));
|
|
1218
|
+
}
|
|
871
1219
|
// ─── Wait Conditions ─────────────────────────────────────────────────────────
|
|
872
1220
|
async wait(taskId, type, value, options = {}) {
|
|
873
1221
|
const timeout = options.timeout ?? 30000;
|
|
@@ -969,8 +1317,37 @@ export class BrowserService {
|
|
|
969
1317
|
return undefined;
|
|
970
1318
|
}
|
|
971
1319
|
async shutdown() {
|
|
1320
|
+
// Drain any in-flight recordings first so we don't orphan ffmpeg processes
|
|
1321
|
+
// or leak the duration/size-check timers when the daemon goes down.
|
|
1322
|
+
for (const [taskId, rec] of this.recordings) {
|
|
1323
|
+
clearTimeout(rec.durationTimer);
|
|
1324
|
+
clearInterval(rec.sizeCheckInterval);
|
|
1325
|
+
try {
|
|
1326
|
+
rec.conn.cdp.off('Page.screencastFrame', rec.frameHandler);
|
|
1327
|
+
}
|
|
1328
|
+
catch { /* socket may be gone */ }
|
|
1329
|
+
try {
|
|
1330
|
+
rec.ffmpeg.stdin?.end();
|
|
1331
|
+
}
|
|
1332
|
+
catch { /* already closed */ }
|
|
1333
|
+
// Give ffmpeg up to 1s to flush; then SIGKILL.
|
|
1334
|
+
const exited = await new Promise((resolve) => {
|
|
1335
|
+
let done = false;
|
|
1336
|
+
rec.ffmpeg.once('exit', () => { done = true; resolve(true); });
|
|
1337
|
+
setTimeout(() => { if (!done)
|
|
1338
|
+
resolve(false); }, 1000);
|
|
1339
|
+
});
|
|
1340
|
+
if (!exited) {
|
|
1341
|
+
try {
|
|
1342
|
+
rec.ffmpeg.kill('SIGKILL');
|
|
1343
|
+
}
|
|
1344
|
+
catch { /* already dead */ }
|
|
1345
|
+
}
|
|
1346
|
+
this.recordings.delete(taskId);
|
|
1347
|
+
}
|
|
972
1348
|
for (const [, conn] of this.connections) {
|
|
973
1349
|
conn.cdp.close();
|
|
1350
|
+
conn.cleanup?.();
|
|
974
1351
|
}
|
|
975
1352
|
this.connections.clear();
|
|
976
1353
|
}
|
|
@@ -990,7 +1367,7 @@ export class BrowserService {
|
|
|
990
1367
|
const forkName = `${profile.name}.${forkNum}`;
|
|
991
1368
|
const port = allocatePort();
|
|
992
1369
|
const chromeOpts = { ...profile.chrome, viewport: profile.viewport };
|
|
993
|
-
const { pid, wsUrl } = await launchBrowser(forkName, profile.browser, port, chromeOpts, profile.secrets, profile.binary);
|
|
1370
|
+
const { pid, wsUrl } = await launchBrowser(forkName, profile.browser, port, chromeOpts, profile.secrets, profile.binary, profile.electron === true);
|
|
994
1371
|
const cdp = new CDPClient();
|
|
995
1372
|
await cdp.connect(wsUrl);
|
|
996
1373
|
await this.enableDomains(cdp);
|
|
@@ -1007,39 +1384,50 @@ export class BrowserService {
|
|
|
1007
1384
|
this.connections.set(forkName, connection);
|
|
1008
1385
|
return { forkName, connection };
|
|
1009
1386
|
}
|
|
1010
|
-
|
|
1011
|
-
|
|
1387
|
+
/**
|
|
1388
|
+
* Connect to a profile at a specific endpoint preset. The caller has
|
|
1389
|
+
* already resolved the endpoint and built the `effectiveProfile` with
|
|
1390
|
+
* the per-endpoint binary/targetFilter overrides applied; we just use it.
|
|
1391
|
+
*
|
|
1392
|
+
* `effectiveProfile.name` is the composite identifier (`<profile>@<endpoint>`)
|
|
1393
|
+
* so per-endpoint pid/port files don't collide when the same app runs
|
|
1394
|
+
* locally and remotely at the same time.
|
|
1395
|
+
*/
|
|
1396
|
+
async connectProfile(effectiveProfile, target) {
|
|
1397
|
+
const existingInfo = getRunningChromeInfo(effectiveProfile.name);
|
|
1012
1398
|
if (existingInfo) {
|
|
1013
|
-
const { wsUrl, browser } = await discoverBrowserWsUrl(existingInfo.port);
|
|
1014
|
-
verifyBrowserIdentity(browser, profile.browser, existingInfo.port);
|
|
1015
|
-
const cdp = new CDPClient();
|
|
1016
|
-
await cdp.connect(wsUrl);
|
|
1017
|
-
await this.enableDomains(cdp);
|
|
1018
|
-
const tasks = this.loadTaskState(profile.name);
|
|
1019
|
-
return {
|
|
1020
|
-
cdp,
|
|
1021
|
-
port: existingInfo.port,
|
|
1022
|
-
pid: existingInfo.pid,
|
|
1023
|
-
electron: profile.electron,
|
|
1024
|
-
targetFilter: profile.targetFilter,
|
|
1025
|
-
tasks,
|
|
1026
|
-
sessionCache: new Map(),
|
|
1027
|
-
};
|
|
1028
|
-
}
|
|
1029
|
-
for (const endpoint of profile.endpoints) {
|
|
1030
1399
|
try {
|
|
1031
|
-
const
|
|
1032
|
-
|
|
1033
|
-
|
|
1400
|
+
const { wsUrl, browser } = await discoverBrowserWsUrl(existingInfo.port);
|
|
1401
|
+
verifyBrowserIdentity(browser, effectiveProfile.browser, existingInfo.port);
|
|
1402
|
+
const cdp = new CDPClient();
|
|
1403
|
+
await cdp.connect(wsUrl);
|
|
1404
|
+
await this.enableDomains(cdp);
|
|
1405
|
+
const tasks = this.loadTaskState(effectiveProfile.name);
|
|
1406
|
+
return {
|
|
1407
|
+
cdp,
|
|
1408
|
+
port: existingInfo.port,
|
|
1409
|
+
pid: existingInfo.pid,
|
|
1410
|
+
electron: effectiveProfile.electron,
|
|
1411
|
+
targetFilter: effectiveProfile.targetFilter,
|
|
1412
|
+
tasks,
|
|
1413
|
+
sessionCache: new Map(),
|
|
1414
|
+
};
|
|
1034
1415
|
}
|
|
1035
1416
|
catch (err) {
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
//
|
|
1417
|
+
// pid file says a process is alive on that port, but nothing is
|
|
1418
|
+
// actually responding to CDP — most commonly because the user
|
|
1419
|
+
// changed the configured endpoint port after a previous launch,
|
|
1420
|
+
// or because the OS reused the pid for an unrelated process.
|
|
1421
|
+
// Wipe the stale runtime files and fall through to a fresh
|
|
1422
|
+
// connect against the profile's currently-configured endpoint.
|
|
1423
|
+
clearProfileRuntime(effectiveProfile.name);
|
|
1040
1424
|
}
|
|
1041
1425
|
}
|
|
1042
|
-
|
|
1426
|
+
const conn = await this.connectEndpoint(effectiveProfile, target);
|
|
1427
|
+
if (!conn) {
|
|
1428
|
+
throw new Error(`Could not connect to endpoint ${target} for profile "${effectiveProfile.name}"`);
|
|
1429
|
+
}
|
|
1430
|
+
return conn;
|
|
1043
1431
|
}
|
|
1044
1432
|
async connectEndpoint(profile, endpoint) {
|
|
1045
1433
|
const url = new URL(endpoint);
|
|
@@ -1067,6 +1455,7 @@ export class BrowserService {
|
|
|
1067
1455
|
targetFilter: profile.targetFilter,
|
|
1068
1456
|
tasks: new Map(),
|
|
1069
1457
|
sessionCache: new Map(),
|
|
1458
|
+
cleanup: conn.cleanup,
|
|
1070
1459
|
};
|
|
1071
1460
|
}
|
|
1072
1461
|
if (url.protocol === 'wss:' || url.protocol === 'ws:') {
|
|
@@ -1143,6 +1532,21 @@ export class BrowserService {
|
|
|
1143
1532
|
conn.windowId = result.targetId;
|
|
1144
1533
|
return result.targetId;
|
|
1145
1534
|
}
|
|
1535
|
+
hasTaskNamed(name) {
|
|
1536
|
+
for (const conn of this.connections.values()) {
|
|
1537
|
+
if (conn.tasks.has(name))
|
|
1538
|
+
return true;
|
|
1539
|
+
}
|
|
1540
|
+
return false;
|
|
1541
|
+
}
|
|
1542
|
+
generateUniqueTaskName() {
|
|
1543
|
+
for (let attempt = 0; attempt < 8; attempt++) {
|
|
1544
|
+
const candidate = generateTaskName();
|
|
1545
|
+
if (!this.hasTaskNamed(candidate))
|
|
1546
|
+
return candidate;
|
|
1547
|
+
}
|
|
1548
|
+
throw new Error('Could not generate unique task name after 8 attempts');
|
|
1549
|
+
}
|
|
1146
1550
|
async findTask(taskId, profileName) {
|
|
1147
1551
|
if (profileName) {
|
|
1148
1552
|
const conn = this.connections.get(profileName);
|