@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
package/dist/lib/browser/ipc.js
CHANGED
|
@@ -3,6 +3,7 @@ import * as fs from 'fs';
|
|
|
3
3
|
import * as path from 'path';
|
|
4
4
|
import { getHelpersDir } from '../state.js';
|
|
5
5
|
import { startDaemon } from '../daemon.js';
|
|
6
|
+
import { getCliVersion } from '../version.js';
|
|
6
7
|
const SOCKET_NAME = 'browser.sock';
|
|
7
8
|
export function getSocketPath() {
|
|
8
9
|
return path.join(getHelpersDir(), SOCKET_NAME);
|
|
@@ -63,6 +64,9 @@ export class BrowserIPCServer {
|
|
|
63
64
|
}
|
|
64
65
|
async handleRequest(request) {
|
|
65
66
|
switch (request.action) {
|
|
67
|
+
case 'version': {
|
|
68
|
+
return { ok: true, version: getCliVersion() };
|
|
69
|
+
}
|
|
66
70
|
case 'start': {
|
|
67
71
|
if (!request.profile) {
|
|
68
72
|
return { ok: false, error: 'Profile required' };
|
|
@@ -70,6 +74,7 @@ export class BrowserIPCServer {
|
|
|
70
74
|
const result = await this.service.start(request.profile, {
|
|
71
75
|
taskName: request.taskName,
|
|
72
76
|
url: request.url,
|
|
77
|
+
endpointName: request.endpoint,
|
|
73
78
|
});
|
|
74
79
|
return {
|
|
75
80
|
ok: true,
|
|
@@ -78,13 +83,6 @@ export class BrowserIPCServer {
|
|
|
78
83
|
windowTargetId: result.windowId,
|
|
79
84
|
};
|
|
80
85
|
}
|
|
81
|
-
case 'launch-profile': {
|
|
82
|
-
if (!request.profile) {
|
|
83
|
-
return { ok: false, error: 'Profile required' };
|
|
84
|
-
}
|
|
85
|
-
const result = await this.service.launchProfile(request.profile);
|
|
86
|
-
return { ok: true, port: result.port, pid: result.pid };
|
|
87
|
-
}
|
|
88
86
|
case 'done': {
|
|
89
87
|
if (!request.task) {
|
|
90
88
|
return { ok: false, error: 'Task required' };
|
|
@@ -153,12 +151,38 @@ export class BrowserIPCServer {
|
|
|
153
151
|
const result = await this.service.evaluate(request.task, request.tabId, request.expr);
|
|
154
152
|
return { ok: true, result };
|
|
155
153
|
}
|
|
154
|
+
case 'record-start': {
|
|
155
|
+
if (!request.task)
|
|
156
|
+
return { ok: false, error: 'Task required' };
|
|
157
|
+
try {
|
|
158
|
+
const r = await this.service.recordStart(request.task, request.tabId, {
|
|
159
|
+
fps: request.fps,
|
|
160
|
+
duration: request.duration,
|
|
161
|
+
maxMb: request.maxMb,
|
|
162
|
+
});
|
|
163
|
+
return { ok: true, path: r.path, fps: r.fps, durationCapSec: r.durationCapSec, maxMb: r.maxMb };
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
case 'record-stop': {
|
|
170
|
+
if (!request.task)
|
|
171
|
+
return { ok: false, error: 'Task required' };
|
|
172
|
+
try {
|
|
173
|
+
const r = await this.service.recordStop(request.task);
|
|
174
|
+
return { ok: true, path: r.path, bytes: r.bytes, durationMs: r.durationMs, stopReason: r.reason };
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
178
|
+
}
|
|
179
|
+
}
|
|
156
180
|
case 'screenshot': {
|
|
157
181
|
if (!request.task) {
|
|
158
182
|
return { ok: false, error: 'Task required' };
|
|
159
183
|
}
|
|
160
|
-
const
|
|
161
|
-
return { ok: true, path:
|
|
184
|
+
const shot = await this.service.screenshot(request.task, request.tabId, request.path, request.quality);
|
|
185
|
+
return { ok: true, path: shot.path, bytes: shot.bytes, width: shot.width, height: shot.height };
|
|
162
186
|
}
|
|
163
187
|
case 'refs': {
|
|
164
188
|
if (!request.task) {
|
|
@@ -296,6 +320,21 @@ export class BrowserIPCServer {
|
|
|
296
320
|
const downloadPath = await this.service.waitForDownload(request.task, request.timeout);
|
|
297
321
|
return { ok: true, downloadPath };
|
|
298
322
|
}
|
|
323
|
+
case 'getAppLogs': {
|
|
324
|
+
if (!request.task) {
|
|
325
|
+
return { ok: false, error: 'Task required' };
|
|
326
|
+
}
|
|
327
|
+
const appLogs = await this.service.getAppLogs(request.task, {
|
|
328
|
+
lines: request.lines,
|
|
329
|
+
level: request.appLevel,
|
|
330
|
+
filter: request.filter,
|
|
331
|
+
message: request.message,
|
|
332
|
+
source: request.source,
|
|
333
|
+
since: request.since,
|
|
334
|
+
until: request.until,
|
|
335
|
+
});
|
|
336
|
+
return { ok: true, appLogs };
|
|
337
|
+
}
|
|
299
338
|
case 'upload': {
|
|
300
339
|
if (!request.task || !request.files || request.files.length === 0) {
|
|
301
340
|
return { ok: false, error: 'Task and at least one file required' };
|
|
@@ -314,7 +353,43 @@ export class BrowserIPCServer {
|
|
|
314
353
|
}
|
|
315
354
|
}
|
|
316
355
|
}
|
|
356
|
+
let versionCheckedThisProcess = false;
|
|
357
|
+
/**
|
|
358
|
+
* Check the daemon's version against ours and warn loudly when they
|
|
359
|
+
* differ. Fires at most once per CLI process — successive calls in the
|
|
360
|
+
* same `agents browser ...` invocation are cheap. The whole reason this
|
|
361
|
+
* code exists: a launchd-managed registry daemon kept serving stale code
|
|
362
|
+
* to a dev-build CLI for an entire session and nothing surfaced it.
|
|
363
|
+
*/
|
|
364
|
+
async function maybeWarnVersionMismatch() {
|
|
365
|
+
if (versionCheckedThisProcess)
|
|
366
|
+
return;
|
|
367
|
+
versionCheckedThisProcess = true;
|
|
368
|
+
try {
|
|
369
|
+
const resp = await sendRawIPCRequest({ action: 'version' });
|
|
370
|
+
const daemon = resp.version;
|
|
371
|
+
const client = getCliVersion();
|
|
372
|
+
if (!daemon || daemon === 'unknown' || daemon === client)
|
|
373
|
+
return;
|
|
374
|
+
process.stderr.write(`\nwarning: browser daemon is on ${daemon} but this CLI is on ${client}.\n` +
|
|
375
|
+
` Run \`agents daemon restart\` to load the current code.\n\n`);
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
// daemon might be an older build that doesn't speak 'version' — that's
|
|
379
|
+
// itself a hint, but a noisy one. Stay silent on this path.
|
|
380
|
+
}
|
|
381
|
+
}
|
|
317
382
|
export async function sendIPCRequest(request) {
|
|
383
|
+
const result = await sendRawIPCRequest(request);
|
|
384
|
+
// Run the version check after the user's request returns — keeps the
|
|
385
|
+
// critical path zero-overhead and ensures `start` doesn't get blocked
|
|
386
|
+
// on a daemon-restart warning that the user hasn't read yet.
|
|
387
|
+
if (request.action !== 'version') {
|
|
388
|
+
maybeWarnVersionMismatch().catch(() => { });
|
|
389
|
+
}
|
|
390
|
+
return result;
|
|
391
|
+
}
|
|
392
|
+
async function sendRawIPCRequest(request) {
|
|
318
393
|
const socketPath = getSocketPath();
|
|
319
394
|
if (!fs.existsSync(socketPath)) {
|
|
320
395
|
await fs.promises.mkdir(path.dirname(socketPath), { recursive: true });
|
|
@@ -5,15 +5,81 @@ export declare function getProfileRuntimeDir(name: string): string;
|
|
|
5
5
|
export declare function listProfiles(): Promise<BrowserProfile[]>;
|
|
6
6
|
export declare function getProfile(name: string): Promise<BrowserProfile | null>;
|
|
7
7
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* Compute the LOCAL port a profile will occupy at runtime:
|
|
9
|
+
* - `cdp://127.0.0.1:N` → N (we listen on N directly)
|
|
10
|
+
* - `ssh://host?port=N` → N (the SSH tunnel binds local N → remote N now)
|
|
11
|
+
* - `ws[s]://`, `http[s]://` → undefined (we don't claim a local port)
|
|
12
|
+
*
|
|
13
|
+
* This is what callers should compare to detect collisions; the (host,
|
|
14
|
+
* port) tuple is no longer enough because SSH profiles do compete with
|
|
15
|
+
* cdp:// profiles for local ports under the new tunnel scheme.
|
|
16
|
+
*/
|
|
17
|
+
export declare function effectiveLocalPort(profile: BrowserProfile): number | undefined;
|
|
18
|
+
/**
|
|
19
|
+
* Find a port in 9222–9399 that is not already claimed by ANY existing
|
|
20
|
+
* profile (cdp:// or ssh://) and is not in use by any OS process. The
|
|
21
|
+
* SSH change to bind locally on `?port=N` means we no longer get to
|
|
22
|
+
* skip remote profiles in this scan.
|
|
10
23
|
*/
|
|
11
24
|
export declare function findFreeProfilePort(): Promise<number>;
|
|
12
25
|
export declare function createProfile(profile: BrowserProfile): Promise<void>;
|
|
13
26
|
export declare function updateProfile(profile: BrowserProfile): Promise<void>;
|
|
14
27
|
export declare function deleteProfile(name: string): Promise<void>;
|
|
15
28
|
/**
|
|
16
|
-
*
|
|
29
|
+
* Resolve a profile's endpoint presets into a normalized map regardless of
|
|
30
|
+
* whether the YAML uses the legacy `string[]` shape or the new map shape.
|
|
31
|
+
* The legacy entries get auto-named `endpoint-0`, `endpoint-1`, ... .
|
|
32
|
+
*/
|
|
33
|
+
export declare function getEndpointPresets(profile: BrowserProfile): Record<string, import('./types.js').EndpointPreset>;
|
|
34
|
+
/**
|
|
35
|
+
* Pick the endpoint preset to use. Order:
|
|
36
|
+
* 1. Explicit name passed in (errors if unknown)
|
|
37
|
+
* 2. `profile.defaultEndpoint` if set
|
|
38
|
+
* 3. First entry (preserves legacy string[] behavior)
|
|
39
|
+
*
|
|
40
|
+
* Returns the resolved name + the preset (with per-endpoint overrides
|
|
41
|
+
* already applied to binary / targetFilter), so callers don't have to
|
|
42
|
+
* remember the precedence rules.
|
|
43
|
+
*/
|
|
44
|
+
export declare function resolveEndpoint(profile: BrowserProfile, endpointName?: string): {
|
|
45
|
+
name: string;
|
|
46
|
+
target: string;
|
|
47
|
+
binary?: string;
|
|
48
|
+
targetFilter?: string;
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* Extract the (host, port) pair intended by the profile's default endpoint.
|
|
52
|
+
* Returns undefined for endpoint shapes that don't carry a port (e.g. ws:// without one).
|
|
53
|
+
*
|
|
54
|
+
* Ports are scoped by host: a `cdp://127.0.0.1:9222` profile (local Chrome on
|
|
55
|
+
* this machine) and an `ssh://mac-mini:9222` profile (Comet on mac-mini)
|
|
56
|
+
* point at different physical ports — the host disambiguates them.
|
|
57
|
+
*
|
|
58
|
+
* Accepts both `scheme://host:port` and `scheme://host?port=N` shapes (the
|
|
59
|
+
* latter is the documented form in `types.ts` for `ssh://`). Without this,
|
|
60
|
+
* `ssh://mac-mini?port=18805` would silently fall back to 9222 and every
|
|
61
|
+
* `?port=`-style SSH profile would collide on creation.
|
|
62
|
+
*/
|
|
63
|
+
export declare function extractConfiguredEndpoint(profile: BrowserProfile): {
|
|
64
|
+
host: string;
|
|
65
|
+
port: number;
|
|
66
|
+
} | undefined;
|
|
67
|
+
/**
|
|
68
|
+
* Shared endpoint parser used by both the collision-detection code path and
|
|
69
|
+
* the connection drivers. Returning a single normalized `(host, port)` here
|
|
70
|
+
* keeps `extractConfiguredEndpoint` and the SSH driver from drifting on URL
|
|
71
|
+
* conventions (which is how `?port=N` ended up being silently ignored).
|
|
72
|
+
*/
|
|
73
|
+
export declare function parseEndpointUrl(endpoint: string): {
|
|
74
|
+
host: string;
|
|
75
|
+
port: number;
|
|
76
|
+
} | undefined;
|
|
77
|
+
/**
|
|
78
|
+
* Extract the port intended by the profile's default endpoint.
|
|
17
79
|
* Returns undefined for endpoint shapes that don't carry a port (e.g. ws:// without one).
|
|
80
|
+
*
|
|
81
|
+
* Note: this loses the host dimension — for collision detection use
|
|
82
|
+
* `extractConfiguredEndpoint` instead, which returns the (host, port) pair.
|
|
18
83
|
*/
|
|
19
84
|
export declare function extractConfiguredPort(profile: BrowserProfile): number | undefined;
|
|
85
|
+
export declare function isLocalHost(host: string): boolean;
|
|
@@ -17,9 +17,12 @@ function configToProfile(name, config) {
|
|
|
17
17
|
electron: config.electron,
|
|
18
18
|
targetFilter: config.targetFilter,
|
|
19
19
|
endpoints: config.endpoints,
|
|
20
|
+
defaultEndpoint: config.defaultEndpoint,
|
|
20
21
|
chrome: config.chrome,
|
|
21
22
|
secrets: config.secrets,
|
|
22
23
|
viewport: config.viewport,
|
|
24
|
+
logDir: config.logDir,
|
|
25
|
+
logHost: config.logHost,
|
|
23
26
|
};
|
|
24
27
|
}
|
|
25
28
|
function profileToConfig(profile) {
|
|
@@ -35,12 +38,18 @@ function profileToConfig(profile) {
|
|
|
35
38
|
config.electron = profile.electron;
|
|
36
39
|
if (profile.targetFilter)
|
|
37
40
|
config.targetFilter = profile.targetFilter;
|
|
41
|
+
if (profile.defaultEndpoint)
|
|
42
|
+
config.defaultEndpoint = profile.defaultEndpoint;
|
|
38
43
|
if (profile.chrome)
|
|
39
44
|
config.chrome = profile.chrome;
|
|
40
45
|
if (profile.secrets)
|
|
41
46
|
config.secrets = profile.secrets;
|
|
42
47
|
if (profile.viewport)
|
|
43
48
|
config.viewport = profile.viewport;
|
|
49
|
+
if (profile.logDir)
|
|
50
|
+
config.logDir = profile.logDir;
|
|
51
|
+
if (profile.logHost)
|
|
52
|
+
config.logHost = profile.logHost;
|
|
44
53
|
return config;
|
|
45
54
|
}
|
|
46
55
|
export async function listProfiles() {
|
|
@@ -57,14 +66,45 @@ export async function getProfile(name) {
|
|
|
57
66
|
return configToProfile(name, config);
|
|
58
67
|
}
|
|
59
68
|
/**
|
|
60
|
-
*
|
|
61
|
-
*
|
|
69
|
+
* Compute the LOCAL port a profile will occupy at runtime:
|
|
70
|
+
* - `cdp://127.0.0.1:N` → N (we listen on N directly)
|
|
71
|
+
* - `ssh://host?port=N` → N (the SSH tunnel binds local N → remote N now)
|
|
72
|
+
* - `ws[s]://`, `http[s]://` → undefined (we don't claim a local port)
|
|
73
|
+
*
|
|
74
|
+
* This is what callers should compare to detect collisions; the (host,
|
|
75
|
+
* port) tuple is no longer enough because SSH profiles do compete with
|
|
76
|
+
* cdp:// profiles for local ports under the new tunnel scheme.
|
|
77
|
+
*/
|
|
78
|
+
export function effectiveLocalPort(profile) {
|
|
79
|
+
const presets = getEndpointPresets(profile);
|
|
80
|
+
const firstName = profile.defaultEndpoint && presets[profile.defaultEndpoint]
|
|
81
|
+
? profile.defaultEndpoint
|
|
82
|
+
: Object.keys(presets)[0];
|
|
83
|
+
if (!firstName)
|
|
84
|
+
return undefined;
|
|
85
|
+
const target = presets[firstName].target;
|
|
86
|
+
let url;
|
|
87
|
+
try {
|
|
88
|
+
url = new URL(target);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
if (url.protocol !== 'cdp:' && url.protocol !== 'ssh:')
|
|
94
|
+
return undefined;
|
|
95
|
+
return parseEndpointUrl(target)?.port;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Find a port in 9222–9399 that is not already claimed by ANY existing
|
|
99
|
+
* profile (cdp:// or ssh://) and is not in use by any OS process. The
|
|
100
|
+
* SSH change to bind locally on `?port=N` means we no longer get to
|
|
101
|
+
* skip remote profiles in this scan.
|
|
62
102
|
*/
|
|
63
103
|
export async function findFreeProfilePort() {
|
|
64
104
|
const profiles = await listProfiles();
|
|
65
105
|
const usedByProfile = new Set();
|
|
66
106
|
for (const p of profiles) {
|
|
67
|
-
const port =
|
|
107
|
+
const port = effectiveLocalPort(p);
|
|
68
108
|
if (port !== undefined)
|
|
69
109
|
usedByProfile.add(port);
|
|
70
110
|
}
|
|
@@ -87,15 +127,20 @@ export async function createProfile(profile) {
|
|
|
87
127
|
if (meta.browser?.[profile.name]) {
|
|
88
128
|
throw new Error(`Profile "${profile.name}" already exists`);
|
|
89
129
|
}
|
|
90
|
-
//
|
|
91
|
-
|
|
92
|
-
|
|
130
|
+
// Collision check. Every CDP/SSH profile ends up listening on (or
|
|
131
|
+
// tunneling to) the same LOCAL port number as the one configured in the
|
|
132
|
+
// endpoint URL — SSH profiles now reuse `?port=N` locally so we no
|
|
133
|
+
// longer need to scope by host. Two profiles that would need the same
|
|
134
|
+
// local port can't both run at the same time.
|
|
135
|
+
const newLocal = effectiveLocalPort(profile);
|
|
136
|
+
if (newLocal !== undefined && meta.browser) {
|
|
93
137
|
for (const [existingName, existingConfig] of Object.entries(meta.browser)) {
|
|
94
138
|
const existingProfile = configToProfile(existingName, existingConfig);
|
|
95
|
-
const
|
|
96
|
-
if (
|
|
97
|
-
throw new Error(`
|
|
98
|
-
`Each profile must own a unique port
|
|
139
|
+
const existingLocal = effectiveLocalPort(existingProfile);
|
|
140
|
+
if (existingLocal === newLocal) {
|
|
141
|
+
throw new Error(`Local port ${newLocal} is already used by profile "${existingName}". ` +
|
|
142
|
+
`Each profile must own a unique local port (SSH tunnels now bind ` +
|
|
143
|
+
`to their configured port locally too). Pick a different port.`);
|
|
99
144
|
}
|
|
100
145
|
}
|
|
101
146
|
}
|
|
@@ -125,13 +170,87 @@ export async function deleteProfile(name) {
|
|
|
125
170
|
writeMeta(meta);
|
|
126
171
|
}
|
|
127
172
|
/**
|
|
128
|
-
*
|
|
173
|
+
* Resolve a profile's endpoint presets into a normalized map regardless of
|
|
174
|
+
* whether the YAML uses the legacy `string[]` shape or the new map shape.
|
|
175
|
+
* The legacy entries get auto-named `endpoint-0`, `endpoint-1`, ... .
|
|
176
|
+
*/
|
|
177
|
+
export function getEndpointPresets(profile) {
|
|
178
|
+
if (Array.isArray(profile.endpoints)) {
|
|
179
|
+
const out = {};
|
|
180
|
+
profile.endpoints.forEach((target, i) => {
|
|
181
|
+
out[`endpoint-${i}`] = { target };
|
|
182
|
+
});
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
return profile.endpoints;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Pick the endpoint preset to use. Order:
|
|
189
|
+
* 1. Explicit name passed in (errors if unknown)
|
|
190
|
+
* 2. `profile.defaultEndpoint` if set
|
|
191
|
+
* 3. First entry (preserves legacy string[] behavior)
|
|
192
|
+
*
|
|
193
|
+
* Returns the resolved name + the preset (with per-endpoint overrides
|
|
194
|
+
* already applied to binary / targetFilter), so callers don't have to
|
|
195
|
+
* remember the precedence rules.
|
|
196
|
+
*/
|
|
197
|
+
export function resolveEndpoint(profile, endpointName) {
|
|
198
|
+
const presets = getEndpointPresets(profile);
|
|
199
|
+
const names = Object.keys(presets);
|
|
200
|
+
if (names.length === 0) {
|
|
201
|
+
throw new Error(`Profile "${profile.name}" has no endpoints configured`);
|
|
202
|
+
}
|
|
203
|
+
let chosenName;
|
|
204
|
+
if (endpointName) {
|
|
205
|
+
if (!presets[endpointName]) {
|
|
206
|
+
throw new Error(`Endpoint "${endpointName}" not found on profile "${profile.name}". ` +
|
|
207
|
+
`Available: ${names.join(', ')}`);
|
|
208
|
+
}
|
|
209
|
+
chosenName = endpointName;
|
|
210
|
+
}
|
|
211
|
+
else if (profile.defaultEndpoint && presets[profile.defaultEndpoint]) {
|
|
212
|
+
chosenName = profile.defaultEndpoint;
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
chosenName = names[0];
|
|
216
|
+
}
|
|
217
|
+
const preset = presets[chosenName];
|
|
218
|
+
return {
|
|
219
|
+
name: chosenName,
|
|
220
|
+
target: preset.target,
|
|
221
|
+
binary: preset.binary ?? profile.binary,
|
|
222
|
+
targetFilter: preset.targetFilter ?? profile.targetFilter,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Extract the (host, port) pair intended by the profile's default endpoint.
|
|
129
227
|
* Returns undefined for endpoint shapes that don't carry a port (e.g. ws:// without one).
|
|
228
|
+
*
|
|
229
|
+
* Ports are scoped by host: a `cdp://127.0.0.1:9222` profile (local Chrome on
|
|
230
|
+
* this machine) and an `ssh://mac-mini:9222` profile (Comet on mac-mini)
|
|
231
|
+
* point at different physical ports — the host disambiguates them.
|
|
232
|
+
*
|
|
233
|
+
* Accepts both `scheme://host:port` and `scheme://host?port=N` shapes (the
|
|
234
|
+
* latter is the documented form in `types.ts` for `ssh://`). Without this,
|
|
235
|
+
* `ssh://mac-mini?port=18805` would silently fall back to 9222 and every
|
|
236
|
+
* `?port=`-style SSH profile would collide on creation.
|
|
130
237
|
*/
|
|
131
|
-
export function
|
|
132
|
-
const
|
|
133
|
-
|
|
238
|
+
export function extractConfiguredEndpoint(profile) {
|
|
239
|
+
const presets = getEndpointPresets(profile);
|
|
240
|
+
const firstName = profile.defaultEndpoint && presets[profile.defaultEndpoint]
|
|
241
|
+
? profile.defaultEndpoint
|
|
242
|
+
: Object.keys(presets)[0];
|
|
243
|
+
if (!firstName)
|
|
134
244
|
return undefined;
|
|
245
|
+
return parseEndpointUrl(presets[firstName].target);
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Shared endpoint parser used by both the collision-detection code path and
|
|
249
|
+
* the connection drivers. Returning a single normalized `(host, port)` here
|
|
250
|
+
* keeps `extractConfiguredEndpoint` and the SSH driver from drifting on URL
|
|
251
|
+
* conventions (which is how `?port=N` ended up being silently ignored).
|
|
252
|
+
*/
|
|
253
|
+
export function parseEndpointUrl(endpoint) {
|
|
135
254
|
let url;
|
|
136
255
|
try {
|
|
137
256
|
url = new URL(endpoint);
|
|
@@ -139,11 +258,56 @@ export function extractConfiguredPort(profile) {
|
|
|
139
258
|
catch {
|
|
140
259
|
return undefined;
|
|
141
260
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if (
|
|
147
|
-
return
|
|
261
|
+
const host = normalizeHost(url.hostname, url.protocol);
|
|
262
|
+
if (!host)
|
|
263
|
+
return undefined;
|
|
264
|
+
const port = extractPortFromUrl(url);
|
|
265
|
+
if (port !== undefined)
|
|
266
|
+
return { host, port };
|
|
267
|
+
// SSH endpoints tunnel to a remote port AND bind that same port locally,
|
|
268
|
+
// so they do "own" a local port — the host-scoped collision check used
|
|
269
|
+
// to disagree, but we want the local-port-scoped semantics now.
|
|
270
|
+
if (url.protocol === 'cdp:' || url.protocol === 'ssh:')
|
|
271
|
+
return { host, port: 9222 };
|
|
148
272
|
return undefined;
|
|
149
273
|
}
|
|
274
|
+
function extractPortFromUrl(url) {
|
|
275
|
+
if (url.port) {
|
|
276
|
+
const n = parseInt(url.port, 10);
|
|
277
|
+
if (Number.isFinite(n))
|
|
278
|
+
return n;
|
|
279
|
+
}
|
|
280
|
+
// `scheme://host?port=N` — the form documented for SSH endpoints in
|
|
281
|
+
// `types.ts`. WHATWG URL parsing surfaces it via searchParams only.
|
|
282
|
+
const qp = url.searchParams.get('port');
|
|
283
|
+
if (qp) {
|
|
284
|
+
const n = parseInt(qp, 10);
|
|
285
|
+
if (Number.isFinite(n))
|
|
286
|
+
return n;
|
|
287
|
+
}
|
|
288
|
+
return undefined;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Extract the port intended by the profile's default endpoint.
|
|
292
|
+
* Returns undefined for endpoint shapes that don't carry a port (e.g. ws:// without one).
|
|
293
|
+
*
|
|
294
|
+
* Note: this loses the host dimension — for collision detection use
|
|
295
|
+
* `extractConfiguredEndpoint` instead, which returns the (host, port) pair.
|
|
296
|
+
*/
|
|
297
|
+
export function extractConfiguredPort(profile) {
|
|
298
|
+
return extractConfiguredEndpoint(profile)?.port;
|
|
299
|
+
}
|
|
300
|
+
function normalizeHost(hostname, protocol) {
|
|
301
|
+
if (!hostname) {
|
|
302
|
+
// cdp:// and ssh:// without an explicit host imply localhost.
|
|
303
|
+
if (protocol === 'cdp:' || protocol === 'ssh:')
|
|
304
|
+
return '127.0.0.1';
|
|
305
|
+
return undefined;
|
|
306
|
+
}
|
|
307
|
+
if (hostname === 'localhost')
|
|
308
|
+
return '127.0.0.1';
|
|
309
|
+
return hostname;
|
|
310
|
+
}
|
|
311
|
+
export function isLocalHost(host) {
|
|
312
|
+
return host === '127.0.0.1' || host === 'localhost' || host === '::1';
|
|
313
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-profile runtime files we persist under
|
|
3
|
+
* `~/.agents/.cache/browser/<composite>/`:
|
|
4
|
+
*
|
|
5
|
+
* - `pid` — child process ID we spawned (or 0 if attached to an
|
|
6
|
+
* already-running browser)
|
|
7
|
+
* - `port` — CDP port we ended up speaking on
|
|
8
|
+
* - `command` — basename of the executable so we can defend against pid
|
|
9
|
+
* reuse (`process.kill(pid, 0)` only proves *some* process
|
|
10
|
+
* with that id exists; if the OS recycled it for an
|
|
11
|
+
* unrelated daemon, we'd happily attach to garbage)
|
|
12
|
+
* - `meta.json` — richer record: which daemon spawned us, when, the
|
|
13
|
+
* user-data-dir we wrote into, optional tunnel PID. This
|
|
14
|
+
* is the file the orphan reaper reads on daemon startup.
|
|
15
|
+
* - `tasks.json` — open task state (managed elsewhere by service.ts)
|
|
16
|
+
*
|
|
17
|
+
* The one-value-per-file fields are kept for backward compat with older
|
|
18
|
+
* builds; `meta.json` is additive and consulted preferentially.
|
|
19
|
+
*/
|
|
20
|
+
export interface ProfileRuntime {
|
|
21
|
+
pid: number;
|
|
22
|
+
port: number;
|
|
23
|
+
command?: string;
|
|
24
|
+
/** Full path of the user-data-dir we passed to --user-data-dir, used by the reaper to confirm. */
|
|
25
|
+
userDataDir?: string;
|
|
26
|
+
/** PID of the daemon that spawned this. When the daemon dies, the next one reaps. */
|
|
27
|
+
daemonPid?: number;
|
|
28
|
+
/** Wall-clock time of spawn — useful for diagnostics and TTL-based cleanup. */
|
|
29
|
+
spawnedAt?: number;
|
|
30
|
+
/** What kind of process: 'browser' (Chrome-family), 'electron' (Notion etc.), or 'tunnel' (ssh -L). */
|
|
31
|
+
kind?: 'browser' | 'electron' | 'tunnel';
|
|
32
|
+
/** Local ssh -L PID, if this profile is SSH-backed. Distinct from `pid` (which is the remote browser, normally 0). */
|
|
33
|
+
tunnelPid?: number;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Save the runtime record atomically. We write the legacy one-value-per-
|
|
37
|
+
* file fields plus a JSON meta blob so future code can read either.
|
|
38
|
+
* The cache directory may not exist yet (first launch); we create it.
|
|
39
|
+
*/
|
|
40
|
+
export declare function writeProfileRuntime(profileName: string, runtime: ProfileRuntime): void;
|
|
41
|
+
/** Read just the JSON meta record. Returns null when absent or malformed. */
|
|
42
|
+
export declare function readProfileRuntimeMeta(profileName: string): ProfileRuntime | null;
|
|
43
|
+
/**
|
|
44
|
+
* Read the runtime triple. Returns null when the files are missing OR when
|
|
45
|
+
* the recorded pid no longer points at the same process we launched —
|
|
46
|
+
* stale data is auto-cleaned to keep the next caller from acting on it.
|
|
47
|
+
*/
|
|
48
|
+
export declare function readProfileRuntime(profileName: string): ProfileRuntime | null;
|
|
49
|
+
/** Remove the pid/port/command/meta files. Leaves chrome-data + tasks.json intact. */
|
|
50
|
+
export declare function clearProfileRuntime(profileName: string): void;
|
|
51
|
+
/**
|
|
52
|
+
* Recursively remove the whole profile cache (chrome-data, tasks.json,
|
|
53
|
+
* everything). Used by `profiles delete` so an old profile name doesn't
|
|
54
|
+
* leak its history into a freshly-recreated one.
|
|
55
|
+
*/
|
|
56
|
+
export declare function removeProfileCache(profileName: string): void;
|
|
57
|
+
/**
|
|
58
|
+
* Find every cache directory belonging to a given profile. The composite
|
|
59
|
+
* naming (`<name>@<endpoint>`) means a single agents-cli profile can have
|
|
60
|
+
* multiple runtime dirs side by side; this finds them all plus the legacy
|
|
61
|
+
* non-composite dir from older builds.
|
|
62
|
+
*/
|
|
63
|
+
export declare function listProfileCacheDirs(profileName: string): string[];
|
|
64
|
+
/**
|
|
65
|
+
* `process.kill(pid, 0)` answers "is a process with this id alive?" — but
|
|
66
|
+
* pid reuse is real on long-uptime machines, and a stale cache pointing
|
|
67
|
+
* at a since-reassigned pid would happily call the imposter ours.
|
|
68
|
+
*
|
|
69
|
+
* Strategy: if we recorded the executable basename when we launched, ask
|
|
70
|
+
* `ps` what command the live pid is running and compare. No command on
|
|
71
|
+
* record means we fall back to the existence check (older cache entries
|
|
72
|
+
* or `pid:0` for "attached to an externally-launched browser").
|
|
73
|
+
*/
|
|
74
|
+
export declare function isProcessAlive(pid: number, expectedCommand?: string): boolean;
|
|
75
|
+
/**
|
|
76
|
+
* Snapshot of one tracked profile, suitable for `agents browser ps` output.
|
|
77
|
+
* Combines the on-disk meta record with live-process probes so callers can
|
|
78
|
+
* tell at a glance which entries are alive, stale, or have outright leaked.
|
|
79
|
+
*/
|
|
80
|
+
export interface ProfileSnapshot {
|
|
81
|
+
/** Composite name as the cache dir is keyed: `<profile>` or `<profile>@<endpoint>`. */
|
|
82
|
+
name: string;
|
|
83
|
+
/** Absolute path of the cache dir. */
|
|
84
|
+
dir: string;
|
|
85
|
+
meta: ProfileRuntime | null;
|
|
86
|
+
/** Live-process probe: does the recorded pid still exist + match command? */
|
|
87
|
+
pidAlive: boolean;
|
|
88
|
+
/** Live-process probe for the tunnel pid (SSH profiles). */
|
|
89
|
+
tunnelAlive: boolean;
|
|
90
|
+
/** True iff the daemon that started this is still alive. False == orphaned. */
|
|
91
|
+
daemonAlive: boolean;
|
|
92
|
+
/** Number of tasks recorded in tasks.json (open browser tabs). */
|
|
93
|
+
taskCount: number;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Read every profile cache directory and produce a structured snapshot.
|
|
97
|
+
* Works without the daemon — `agents browser ps` uses this to render a
|
|
98
|
+
* complete state view even when the IPC server is down. The caller can
|
|
99
|
+
* post-process to detect conflicts (e.g. two profiles with the same port,
|
|
100
|
+
* or a port someone else is listening on).
|
|
101
|
+
*/
|
|
102
|
+
export declare function listAllProfileSnapshots(): ProfileSnapshot[];
|
|
103
|
+
/**
|
|
104
|
+
* Reap browser + tunnel processes spawned by daemons that no longer exist.
|
|
105
|
+
* Call once on daemon startup. The idea: every process we spawn records
|
|
106
|
+
* its daemonPid in meta.json. If that daemon is dead (crashed, SIGKILL),
|
|
107
|
+
* its children were left rootless — kill them now so they don't hijack
|
|
108
|
+
* the next session's local ports.
|
|
109
|
+
*
|
|
110
|
+
* We're conservative: a record with no daemonPid (older builds) is left
|
|
111
|
+
* alone — we'd rather leak than wrongly kill a user-owned process that
|
|
112
|
+
* happens to share metadata.
|
|
113
|
+
*/
|
|
114
|
+
export declare function reapOrphanedProcesses(): {
|
|
115
|
+
reaped: number;
|
|
116
|
+
details: string[];
|
|
117
|
+
};
|