@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/commands/factory.js
CHANGED
|
@@ -3,6 +3,7 @@ import * as fs from 'fs';
|
|
|
3
3
|
import * as path from 'path';
|
|
4
4
|
import { homedir } from 'os';
|
|
5
5
|
import { betaEnableHint, isBetaEnabled } from '../lib/beta.js';
|
|
6
|
+
import { insertTask } from '../lib/cloud/store.js';
|
|
6
7
|
const FACTORY_URL = process.env.FACTORY_FLOOR_URL ?? 'https://agents.427yosemite.com';
|
|
7
8
|
function die(msg, code = 1) {
|
|
8
9
|
console.error(chalk.red(msg));
|
|
@@ -63,9 +64,20 @@ Examples:
|
|
|
63
64
|
console.log(JSON.stringify(result, null, 2));
|
|
64
65
|
return;
|
|
65
66
|
}
|
|
67
|
+
// Register locally so `agents cloud logs <id>` can find it.
|
|
68
|
+
const now = new Date().toISOString();
|
|
69
|
+
insertTask({
|
|
70
|
+
id: result.cloud_execution_id,
|
|
71
|
+
provider: 'rush',
|
|
72
|
+
status: 'queued',
|
|
73
|
+
agent: 'claude',
|
|
74
|
+
prompt: result.linear_identifier,
|
|
75
|
+
createdAt: now,
|
|
76
|
+
updatedAt: now,
|
|
77
|
+
});
|
|
66
78
|
console.log(chalk.green(`Submitted ${result.linear_identifier} (${result.label})`));
|
|
67
79
|
console.log(` ticket ${result.ticket_id}`);
|
|
68
80
|
console.log(` execution ${result.cloud_execution_id}`);
|
|
69
|
-
console.log(` tail output agents cloud
|
|
81
|
+
console.log(` tail output agents cloud logs ${result.cloud_execution_id}`);
|
|
70
82
|
});
|
|
71
83
|
}
|
package/dist/commands/rules.js
CHANGED
|
@@ -41,6 +41,20 @@ When to use:
|
|
|
41
41
|
- Team onboarding: share rules via 'agents rules add gh:team/standards'
|
|
42
42
|
- Project setup: add project-specific rules with '--scope project'
|
|
43
43
|
- Version testing: install different rule sets per version to A/B test approaches
|
|
44
|
+
|
|
45
|
+
Project rules & @-imports:
|
|
46
|
+
Project rules live in <repo>/.agents/rules/. On every agent launch, the shim compiles
|
|
47
|
+
them into <repo>/AGENTS.md (with CLAUDE.md, GEMINI.md, .cursorrules symlinked to it).
|
|
48
|
+
A hand-authored AGENTS.md is preserved — the compile pipeline only overwrites files
|
|
49
|
+
it owns (those starting with the auto-compile header). Delete the file to migrate.
|
|
50
|
+
|
|
51
|
+
@path imports inside AGENTS.md/CLAUDE.md are resolved at session start by the agent
|
|
52
|
+
itself, not by agents-cli. Support is per-agent:
|
|
53
|
+
Inlined natively: claude, gemini
|
|
54
|
+
Literal text: codex, cursor, opencode, copilot, amp, kiro, goose, roo
|
|
55
|
+
|
|
56
|
+
For rules that need to work across all agents, inline the content rather than using
|
|
57
|
+
@-imports — the second group will load '@path/to/file.md' as a literal string.
|
|
44
58
|
`);
|
|
45
59
|
rulesCmd
|
|
46
60
|
.command('list [agent]')
|
package/dist/commands/secrets.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import chalk from 'chalk';
|
|
9
9
|
import * as fs from 'fs';
|
|
10
|
-
import { bundleExists, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, migrateLegacyBundles, parseDotenv, readBundle, rotateBundleSecret, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, } from '../lib/secrets/bundles.js';
|
|
10
|
+
import { bundleExists, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, migrateLegacyBundles, parseDotenv, readBundle, renameBundle, rotateBundleSecret, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, } from '../lib/secrets/bundles.js';
|
|
11
11
|
import { deleteKeychainToken, getKeychainToken, hasKeychainToken, secretsKeychainItem, setKeychainToken, } from '../lib/secrets/index.js';
|
|
12
12
|
import { assertOpAvailable, createPasswordItem, deleteItemByTitle, extractSecrets, itemExistsByTitle, listItems, listVaults, } from '../lib/onepassword.js';
|
|
13
13
|
import { registerCommandGroups } from '../lib/help.js';
|
|
@@ -314,7 +314,7 @@ function countExpiringSoon(meta) {
|
|
|
314
314
|
export function registerSecretsCommands(program) {
|
|
315
315
|
const cmd = program
|
|
316
316
|
.command('secrets')
|
|
317
|
-
.description('Named bundles of env variables backed by macOS Keychain (
|
|
317
|
+
.description('Named bundles of env variables backed by macOS Keychain (iCloud-synced by default). Inject into agents via `agents run --secrets <name>`.')
|
|
318
318
|
.hook('preAction', () => { migrateLegacyBundles(); })
|
|
319
319
|
.addHelpText('after', `
|
|
320
320
|
Workflow:
|
|
@@ -323,16 +323,24 @@ Workflow:
|
|
|
323
323
|
run with --secrets <name>. Keychain-backed values never touch disk in
|
|
324
324
|
plaintext.
|
|
325
325
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
326
|
+
New bundles store values in the iCloud-synced keychain by default so they
|
|
327
|
+
appear automatically on your other Macs (same iCloud account, iCloud
|
|
328
|
+
Keychain enabled). Pass --no-icloud-sync at create time to keep values
|
|
329
|
+
device-local instead.
|
|
329
330
|
|
|
330
331
|
Examples:
|
|
331
|
-
# Create a bundle for production credentials
|
|
332
|
+
# Create a bundle for production credentials (iCloud-synced by default)
|
|
332
333
|
agents secrets create prod --description "Production keys for the api stack"
|
|
333
334
|
|
|
334
|
-
# Create a bundle that
|
|
335
|
-
agents secrets create
|
|
335
|
+
# Create a bundle that never leaves this Mac
|
|
336
|
+
agents secrets create local-only --no-icloud-sync
|
|
337
|
+
|
|
338
|
+
# Rename a bundle (moves metadata + every keychain value)
|
|
339
|
+
agents secrets rename prod production
|
|
340
|
+
|
|
341
|
+
# Edit the description of an existing bundle
|
|
342
|
+
agents secrets describe prod "Production keys for the api stack"
|
|
343
|
+
agents secrets describe prod --clear
|
|
336
344
|
|
|
337
345
|
# Add a keychain-backed secret (prompts for the value)
|
|
338
346
|
agents secrets add prod STRIPE_API_KEY
|
|
@@ -387,7 +395,7 @@ Examples:
|
|
|
387
395
|
agents secrets generate 32 --hex
|
|
388
396
|
`);
|
|
389
397
|
registerCommandGroups(cmd, [
|
|
390
|
-
{ title: 'Bundle commands', names: ['list', 'view', 'create', 'delete'] },
|
|
398
|
+
{ title: 'Bundle commands', names: ['list', 'view', 'create', 'rename', 'describe', 'delete'] },
|
|
391
399
|
{ title: 'Secret commands', names: ['add', 'rotate', 'remove', 'import', 'export'] },
|
|
392
400
|
{ title: 'Utilities', names: ['exec', 'generate'] },
|
|
393
401
|
]);
|
|
@@ -484,7 +492,7 @@ Examples:
|
|
|
484
492
|
.description('Create an empty bundle')
|
|
485
493
|
.option('--description <text>', 'Free-form description')
|
|
486
494
|
.option('--allow-exec', 'Allow exec: refs in this bundle (off by default)')
|
|
487
|
-
.option('--icloud-sync', 'Store keychain values
|
|
495
|
+
.option('--no-icloud-sync', 'Store keychain values device-local instead of syncing them via iCloud Keychain')
|
|
488
496
|
.option('--force', 'Overwrite an existing bundle')
|
|
489
497
|
.action(async (name, opts) => {
|
|
490
498
|
try {
|
|
@@ -498,7 +506,7 @@ Examples:
|
|
|
498
506
|
name: resolvedName,
|
|
499
507
|
description: opts.description,
|
|
500
508
|
allow_exec: opts.allowExec,
|
|
501
|
-
icloud_sync: opts.icloudSync,
|
|
509
|
+
icloud_sync: opts.icloudSync !== false,
|
|
502
510
|
vars: {},
|
|
503
511
|
};
|
|
504
512
|
writeBundle(bundle);
|
|
@@ -512,6 +520,53 @@ Examples:
|
|
|
512
520
|
process.exit(1);
|
|
513
521
|
}
|
|
514
522
|
});
|
|
523
|
+
cmd
|
|
524
|
+
.command('rename <old> <new>')
|
|
525
|
+
.alias('mv')
|
|
526
|
+
.description('Rename a bundle. Moves the metadata and every keychain-backed value to the new name.')
|
|
527
|
+
.option('--force', 'Overwrite the destination bundle if it already exists (purges its keychain items first)')
|
|
528
|
+
.action((oldName, newName, opts) => {
|
|
529
|
+
try {
|
|
530
|
+
renameBundle(oldName, newName, { force: opts.force });
|
|
531
|
+
console.log(chalk.green(`Bundle '${oldName}' renamed to '${newName}'.`));
|
|
532
|
+
}
|
|
533
|
+
catch (err) {
|
|
534
|
+
console.error(chalk.red(err.message));
|
|
535
|
+
process.exit(1);
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
cmd
|
|
539
|
+
.command('describe <name> [text...]')
|
|
540
|
+
.description('Update the description of a bundle. Pass --clear to remove it.')
|
|
541
|
+
.option('--clear', 'Remove the existing description')
|
|
542
|
+
.action((name, textParts, opts) => {
|
|
543
|
+
try {
|
|
544
|
+
const bundle = readBundle(name);
|
|
545
|
+
const text = textParts.join(' ').trim();
|
|
546
|
+
if (opts.clear) {
|
|
547
|
+
if (text) {
|
|
548
|
+
console.error(chalk.red('Pass either description text or --clear, not both.'));
|
|
549
|
+
process.exit(1);
|
|
550
|
+
}
|
|
551
|
+
bundle.description = undefined;
|
|
552
|
+
}
|
|
553
|
+
else {
|
|
554
|
+
if (!text) {
|
|
555
|
+
console.error(chalk.red('Description text is required. Pass it as an argument or use --clear.'));
|
|
556
|
+
process.exit(1);
|
|
557
|
+
}
|
|
558
|
+
bundle.description = text;
|
|
559
|
+
}
|
|
560
|
+
writeBundle(bundle);
|
|
561
|
+
console.log(chalk.green(opts.clear
|
|
562
|
+
? `Bundle '${name}' description cleared.`
|
|
563
|
+
: `Bundle '${name}' description updated.`));
|
|
564
|
+
}
|
|
565
|
+
catch (err) {
|
|
566
|
+
console.error(chalk.red(err.message));
|
|
567
|
+
process.exit(1);
|
|
568
|
+
}
|
|
569
|
+
});
|
|
515
570
|
cmd
|
|
516
571
|
.command('add [bundle] [key]')
|
|
517
572
|
.description('Add a variable to a bundle. Defaults to keychain-backed; pass --value for literal, --env/--file/--exec for refs.')
|
package/dist/lib/browser/cdp.js
CHANGED
|
@@ -14,7 +14,13 @@ export class CDPClient {
|
|
|
14
14
|
}
|
|
15
15
|
async send(method, params, sessionId) {
|
|
16
16
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
17
|
-
|
|
17
|
+
// Reached when the underlying browser was killed externally between
|
|
18
|
+
// the daemon establishing the connection and a CDP call going out.
|
|
19
|
+
// The service-layer healthcheck normally catches this on the next
|
|
20
|
+
// `start`, so seeing this in the wild means a request landed against
|
|
21
|
+
// an in-flight conn that just died — tell the user how to recover.
|
|
22
|
+
throw new Error('CDP connection not open — the browser was likely closed externally. ' +
|
|
23
|
+
'Run `agents browser stop --profile <name>` (or restart the daemon) and try again.');
|
|
18
24
|
}
|
|
19
25
|
const id = ++this.messageId;
|
|
20
26
|
const message = sessionId
|
|
@@ -6,7 +6,7 @@ export interface LaunchResult {
|
|
|
6
6
|
port: number;
|
|
7
7
|
wsUrl: string;
|
|
8
8
|
}
|
|
9
|
-
export declare function launchBrowser(profileName: string, browserType: BrowserType, port: number, options?: ChromeOptions, secrets?: string, customBinary?: string): Promise<LaunchResult>;
|
|
9
|
+
export declare function launchBrowser(profileName: string, browserType: BrowserType, port: number, options?: ChromeOptions, secrets?: string, customBinary?: string, isElectron?: boolean): Promise<LaunchResult>;
|
|
10
10
|
export declare function attachToChrome(port: number): Promise<string>;
|
|
11
11
|
export declare function killChrome(pid: number): void;
|
|
12
12
|
export declare function getRunningChromeInfo(profileName: string): {
|
|
@@ -5,6 +5,7 @@ import * as os from 'os';
|
|
|
5
5
|
import { getProfileRuntimeDir } from './profiles.js';
|
|
6
6
|
import { discoverBrowserWsUrl } from './cdp.js';
|
|
7
7
|
import { readBundle, resolveBundleEnv, bundleExists } from '../secrets/bundles.js';
|
|
8
|
+
import { writeProfileRuntime, readProfileRuntime } from './runtime-state.js';
|
|
8
9
|
const BROWSER_PATHS = {
|
|
9
10
|
darwin: {
|
|
10
11
|
chrome: [
|
|
@@ -64,11 +65,27 @@ export function findBrowserPath(browserType, customBinary) {
|
|
|
64
65
|
}
|
|
65
66
|
throw new Error(`Browser "${browserType}" not found. Install it first.`);
|
|
66
67
|
}
|
|
67
|
-
export async function launchBrowser(profileName, browserType, port, options = {}, secrets, customBinary
|
|
68
|
+
export async function launchBrowser(profileName, browserType, port, options = {}, secrets, customBinary,
|
|
69
|
+
// `electron: true` distinguishes Notion / VS Code-style apps from
|
|
70
|
+
// regular Chrome — purely informational, stored in meta.json so the
|
|
71
|
+
// orphan reaper and `agents browser status` can label processes.
|
|
72
|
+
isElectron = false) {
|
|
68
73
|
const browserPath = findBrowserPath(browserType, customBinary);
|
|
69
74
|
const runtimeDir = getProfileRuntimeDir(profileName);
|
|
70
75
|
const userDataDir = path.join(runtimeDir, 'chrome-data');
|
|
71
76
|
fs.mkdirSync(userDataDir, { recursive: true });
|
|
77
|
+
// First-launch seed: stamp the user-data-dir's Default/Preferences with
|
|
78
|
+
// the agents-cli profile name so Chromium's UI shows "<profile>" instead
|
|
79
|
+
// of its default "Person 1". Done only when the file doesn't exist —
|
|
80
|
+
// subsequent launches inherit whatever Chrome wrote in the meantime.
|
|
81
|
+
seedDefaultProfileName(userDataDir, profileName);
|
|
82
|
+
// Chromium on macOS coordinates instances via the SingletonLock file
|
|
83
|
+
// *inside* each user-data-dir. Direct binary spawn with a fresh
|
|
84
|
+
// --user-data-dir creates a fully independent process — the user's
|
|
85
|
+
// normal browser (running under their default user-data-dir) and our
|
|
86
|
+
// sandboxed one coexist as two real processes. The macOS Dock collapses
|
|
87
|
+
// them into one icon per .app bundle, which makes it look like a single
|
|
88
|
+
// instance, but `ps -ww` will show both.
|
|
72
89
|
const viewport = options.viewport ?? { width: 1512, height: 982 };
|
|
73
90
|
const args = [
|
|
74
91
|
`--remote-debugging-port=${port}`,
|
|
@@ -77,6 +94,12 @@ export async function launchBrowser(profileName, browserType, port, options = {}
|
|
|
77
94
|
'--disable-background-timer-throttling',
|
|
78
95
|
'--disable-backgrounding-occluded-windows',
|
|
79
96
|
'--disable-renderer-backgrounding',
|
|
97
|
+
// First-run + default-browser modals block automation: when targetFilter
|
|
98
|
+
// matches by URL, the onboarding page (`chrome://welcome/`) isn't a
|
|
99
|
+
// match and start fails with "no page target". Suppress them.
|
|
100
|
+
'--no-first-run',
|
|
101
|
+
'--no-default-browser-check',
|
|
102
|
+
'--disable-features=DefaultBrowserSetting,ChromeWhatsNewUI',
|
|
80
103
|
...(options.headless ? ['--headless=new'] : []),
|
|
81
104
|
`--window-size=${viewport.width},${viewport.height}`,
|
|
82
105
|
...(viewport.x !== undefined && viewport.y !== undefined
|
|
@@ -102,8 +125,13 @@ export async function launchBrowser(profileName, browserType, port, options = {}
|
|
|
102
125
|
});
|
|
103
126
|
child.unref();
|
|
104
127
|
const pid = child.pid;
|
|
105
|
-
|
|
106
|
-
|
|
128
|
+
writeProfileRuntime(profileName, {
|
|
129
|
+
pid,
|
|
130
|
+
port,
|
|
131
|
+
command: path.basename(browserPath),
|
|
132
|
+
userDataDir,
|
|
133
|
+
kind: isElectron ? 'electron' : 'browser',
|
|
134
|
+
});
|
|
107
135
|
let wsUrl = null;
|
|
108
136
|
for (let i = 0; i < 30; i++) {
|
|
109
137
|
await sleep(200);
|
|
@@ -134,33 +162,31 @@ export function killChrome(pid) {
|
|
|
134
162
|
}
|
|
135
163
|
}
|
|
136
164
|
export function getRunningChromeInfo(profileName) {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
}
|
|
143
|
-
const pid = parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10);
|
|
144
|
-
const port = parseInt(fs.readFileSync(portFile, 'utf-8').trim(), 10);
|
|
145
|
-
if (!isProcessRunning(pid)) {
|
|
146
|
-
fs.unlinkSync(pidFile);
|
|
147
|
-
fs.unlinkSync(portFile);
|
|
165
|
+
// Delegate to runtime-state, which auto-cleans stale files and verifies
|
|
166
|
+
// the live pid still runs the command we recorded — so a recycled pid
|
|
167
|
+
// doesn't masquerade as our browser.
|
|
168
|
+
const rt = readProfileRuntime(profileName);
|
|
169
|
+
if (!rt)
|
|
148
170
|
return null;
|
|
149
|
-
}
|
|
150
|
-
return { pid, port };
|
|
171
|
+
return { pid: rt.pid, port: rt.port };
|
|
151
172
|
}
|
|
152
|
-
|
|
173
|
+
/**
|
|
174
|
+
* Stamp `<userDataDir>/Default/Preferences` with our profile name so
|
|
175
|
+
* Chrome's UI labels the window with the agents-cli name rather than the
|
|
176
|
+
* default "Person 1". Only writes when the file is absent (first launch).
|
|
177
|
+
* Best-effort: any I/O hiccup is silently ignored; missing the rename is
|
|
178
|
+
* cosmetic, not functional.
|
|
179
|
+
*/
|
|
180
|
+
function seedDefaultProfileName(userDataDir, profileName) {
|
|
181
|
+
const defaultDir = path.join(userDataDir, 'Default');
|
|
182
|
+
const prefsPath = path.join(defaultDir, 'Preferences');
|
|
183
|
+
if (fs.existsSync(prefsPath))
|
|
184
|
+
return;
|
|
153
185
|
try {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
}
|
|
157
|
-
catch (err) {
|
|
158
|
-
// EPERM means the process exists but we lack permission to signal it —
|
|
159
|
-
// treat as alive. ESRCH means the process does not exist.
|
|
160
|
-
if (err && err.code === 'EPERM')
|
|
161
|
-
return true;
|
|
162
|
-
return false;
|
|
186
|
+
fs.mkdirSync(defaultDir, { recursive: true });
|
|
187
|
+
fs.writeFileSync(prefsPath, JSON.stringify({ profile: { name: profileName } }));
|
|
163
188
|
}
|
|
189
|
+
catch { /* not critical */ }
|
|
164
190
|
}
|
|
165
191
|
function sleep(ms) {
|
|
166
192
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
import type { DeviceDescriptor } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Default viewport for newly-created profiles. Matches Safari's logical
|
|
4
|
+
* resolution on a 14-inch MacBook Pro (M1/M2/M3 Pro/Max) — the most common
|
|
5
|
+
* shape this CLI sees in practice. Shared with the `MacBook Pro` device
|
|
6
|
+
* preset below so both surfaces agree.
|
|
7
|
+
*/
|
|
8
|
+
export declare const DEFAULT_VIEWPORT: {
|
|
9
|
+
readonly width: 1512;
|
|
10
|
+
readonly height: 982;
|
|
11
|
+
readonly deviceScaleFactor: 2;
|
|
12
|
+
};
|
|
2
13
|
export declare const DEVICES: Record<string, DeviceDescriptor>;
|
|
3
14
|
export declare function getDevice(name: string): DeviceDescriptor | undefined;
|
|
4
15
|
export declare function listDevices(): string[];
|
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default viewport for newly-created profiles. Matches Safari's logical
|
|
3
|
+
* resolution on a 14-inch MacBook Pro (M1/M2/M3 Pro/Max) — the most common
|
|
4
|
+
* shape this CLI sees in practice. Shared with the `MacBook Pro` device
|
|
5
|
+
* preset below so both surfaces agree.
|
|
6
|
+
*/
|
|
7
|
+
export const DEFAULT_VIEWPORT = {
|
|
8
|
+
width: 1512,
|
|
9
|
+
height: 982,
|
|
10
|
+
deviceScaleFactor: 2,
|
|
11
|
+
};
|
|
1
12
|
export const DEVICES = {
|
|
2
13
|
'iPhone 14': {
|
|
3
14
|
width: 390,
|
|
@@ -12,9 +23,9 @@ export const DEVICES = {
|
|
|
12
23
|
mobile: true,
|
|
13
24
|
},
|
|
14
25
|
'MacBook Pro': {
|
|
15
|
-
width:
|
|
16
|
-
height:
|
|
17
|
-
deviceScaleFactor:
|
|
26
|
+
width: DEFAULT_VIEWPORT.width,
|
|
27
|
+
height: DEFAULT_VIEWPORT.height,
|
|
28
|
+
deviceScaleFactor: DEFAULT_VIEWPORT.deviceScaleFactor,
|
|
18
29
|
mobile: false,
|
|
19
30
|
},
|
|
20
31
|
};
|
|
@@ -1,11 +1,38 @@
|
|
|
1
1
|
import { CDPClient, discoverBrowserWsUrl, verifyBrowserIdentity } from '../cdp.js';
|
|
2
2
|
import { launchBrowser, getPortOccupant } from '../chrome.js';
|
|
3
|
+
import { parseEndpointUrl } from '../profiles.js';
|
|
4
|
+
/**
|
|
5
|
+
* Local-port listeners we refuse to attach through. These forward CDP traffic
|
|
6
|
+
* to a remote host — silently using them would let a `cdp://127.0.0.1:N`
|
|
7
|
+
* profile drive a browser on a different machine without the caller realizing.
|
|
8
|
+
*/
|
|
9
|
+
const TUNNEL_PROCESS_NAMES = new Set(['ssh', 'autossh', 'mosh-client', 'socat']);
|
|
10
|
+
function isTunnelProcess(command) {
|
|
11
|
+
return TUNNEL_PROCESS_NAMES.has(command.toLowerCase());
|
|
12
|
+
}
|
|
3
13
|
export async function connectLocal(endpoint, profile) {
|
|
4
14
|
const url = new URL(endpoint);
|
|
5
15
|
if (url.protocol !== 'cdp:') {
|
|
6
16
|
throw new Error(`Invalid local endpoint: ${endpoint}`);
|
|
7
17
|
}
|
|
8
|
-
|
|
18
|
+
// Share the parser with the SSH driver and the collision-detection code
|
|
19
|
+
// path so `cdp://host:N` and `cdp://host?port=N` behave identically.
|
|
20
|
+
const parsed = parseEndpointUrl(endpoint);
|
|
21
|
+
const port = parsed?.port ?? 9222;
|
|
22
|
+
// Refuse to attach through an SSH tunnel before we even try to speak CDP.
|
|
23
|
+
// `verifyBrowserIdentity` only inspects what comes back over the wire — it
|
|
24
|
+
// can't tell whether the browser actually lives on this machine or on the
|
|
25
|
+
// far end of an `ssh -L` tunnel. A stale tunnel from a prior session
|
|
26
|
+
// (common when an SSH-driven profile is deleted before the daemon exits)
|
|
27
|
+
// will silently hijack any "local" profile bound to the same port.
|
|
28
|
+
const preOccupant = getPortOccupant(port);
|
|
29
|
+
if (preOccupant && isTunnelProcess(preOccupant.command)) {
|
|
30
|
+
throw new Error(`Port ${port} is held by ${preOccupant.command} (pid ${preOccupant.pid}), an SSH ` +
|
|
31
|
+
`tunnel forwarding to a remote host. Profile "${profile.name}" is configured as ` +
|
|
32
|
+
`local (${endpoint}) but traffic would round-trip to another machine. Either kill ` +
|
|
33
|
+
`the tunnel (\`kill ${preOccupant.pid}\`) and retry, or switch the profile to an ` +
|
|
34
|
+
`ssh:// endpoint to drive the remote browser explicitly.`);
|
|
35
|
+
}
|
|
9
36
|
try {
|
|
10
37
|
const { wsUrl, browser } = await discoverBrowserWsUrl(port);
|
|
11
38
|
verifyBrowserIdentity(browser, profile.browser, port);
|
|
@@ -30,7 +57,7 @@ export async function connectLocal(endpoint, profile) {
|
|
|
30
57
|
}
|
|
31
58
|
const newPort = port;
|
|
32
59
|
const chromeOpts = { ...profile.chrome, viewport: profile.viewport };
|
|
33
|
-
const { pid, wsUrl } = await launchBrowser(profile.name, profile.browser, newPort, chromeOpts, profile.secrets, profile.binary);
|
|
60
|
+
const { pid, wsUrl } = await launchBrowser(profile.name, profile.browser, newPort, chromeOpts, profile.secrets, profile.binary, profile.electron === true);
|
|
34
61
|
const cdp = new CDPClient();
|
|
35
62
|
await cdp.connect(wsUrl);
|
|
36
63
|
return { cdp, port: newPort, pid };
|
|
@@ -1,23 +1,56 @@
|
|
|
1
|
-
import { spawn } from 'child_process';
|
|
1
|
+
import { spawn, execSync } from 'child_process';
|
|
2
2
|
import * as net from 'net';
|
|
3
3
|
import { CDPClient, discoverBrowserWsUrl, verifyBrowserIdentity } from '../cdp.js';
|
|
4
|
-
import {
|
|
4
|
+
import { getPortOccupant } from '../chrome.js';
|
|
5
|
+
import { parseEndpointUrl } from '../profiles.js';
|
|
6
|
+
import { writeProfileRuntime, clearProfileRuntime } from '../runtime-state.js';
|
|
5
7
|
export async function connectSSH(endpoint, profile) {
|
|
6
8
|
const url = new URL(endpoint);
|
|
7
9
|
if (url.protocol !== 'ssh:') {
|
|
8
10
|
throw new Error(`Invalid SSH endpoint: ${endpoint}`);
|
|
9
11
|
}
|
|
10
12
|
const user = url.username || process.env.USER || 'root';
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
// Use the shared parser so the documented `ssh://host?port=N` form works
|
|
14
|
+
// identically to `ssh://host:N`. Previously `url.port` alone meant every
|
|
15
|
+
// `?port=`-style profile silently fell back to 9222.
|
|
16
|
+
const parsed = parseEndpointUrl(endpoint);
|
|
17
|
+
if (!parsed) {
|
|
18
|
+
throw new Error(`Could not extract host:port from SSH endpoint: ${endpoint}`);
|
|
19
|
+
}
|
|
20
|
+
const host = parsed.host;
|
|
21
|
+
const remotePort = parsed.port;
|
|
22
|
+
// Bind the tunnel to the SAME local port the user configured. Using an
|
|
23
|
+
// allocated port instead made `status` print confusing rows like
|
|
24
|
+
// `port 9200 (configured 10005)` and made it impossible to predict which
|
|
25
|
+
// local port a profile would land on. Now `ssh://host?port=N` => local N.
|
|
26
|
+
const localPort = remotePort;
|
|
27
|
+
// Preflight: if the local port is busy with something that isn't our
|
|
28
|
+
// own SSH tunnel for this very target, bail with a clear error. Letting
|
|
29
|
+
// ssh -L race ahead would either silently succeed (binding to a second
|
|
30
|
+
// port via fail-safe) or fail with cryptic stderr.
|
|
31
|
+
const occupant = getPortOccupant(localPort);
|
|
32
|
+
if (occupant && !isOwnTunnel(occupant.pid, host, remotePort)) {
|
|
33
|
+
throw new Error(`Local port ${localPort} (needed for SSH tunnel to ${host}:${remotePort}) ` +
|
|
34
|
+
`is already in use by ${occupant.command} (pid ${occupant.pid}). ` +
|
|
35
|
+
`Either kill that process (\`kill ${occupant.pid}\`) or change the profile's port.`);
|
|
36
|
+
}
|
|
14
37
|
try {
|
|
15
38
|
await ensureRemoteBrowser(user, host, profile.browser, remotePort, profile.binary);
|
|
16
39
|
}
|
|
17
40
|
catch {
|
|
18
41
|
// Browser may already be running, continue
|
|
19
42
|
}
|
|
20
|
-
let tunnel
|
|
43
|
+
let tunnel;
|
|
44
|
+
if (occupant) {
|
|
45
|
+
// Reuse the existing tunnel rather than spawning a duplicate.
|
|
46
|
+
tunnel = { pid: occupant.pid, kill: () => { try {
|
|
47
|
+
process.kill(occupant.pid);
|
|
48
|
+
}
|
|
49
|
+
catch { /* gone */ } } };
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
tunnel = await startSSHTunnel(user, host, localPort, remotePort);
|
|
53
|
+
}
|
|
21
54
|
try {
|
|
22
55
|
await waitForPort(localPort, 8000);
|
|
23
56
|
}
|
|
@@ -35,13 +68,28 @@ export async function connectSSH(endpoint, profile) {
|
|
|
35
68
|
}
|
|
36
69
|
const cdp = new CDPClient();
|
|
37
70
|
await cdp.connect(wsUrl);
|
|
71
|
+
// Record the tunnel in the profile's runtime so a future daemon — or
|
|
72
|
+
// the orphan reaper after a crash — can find and clean it up. The
|
|
73
|
+
// `kind: 'tunnel'` flag distinguishes it from a locally-launched
|
|
74
|
+
// browser process.
|
|
75
|
+
const tunnelPid = tunnel.pid ?? 0;
|
|
76
|
+
if (tunnelPid > 0) {
|
|
77
|
+
writeProfileRuntime(profile.name, {
|
|
78
|
+
pid: 0,
|
|
79
|
+
port: localPort,
|
|
80
|
+
command: 'ssh',
|
|
81
|
+
kind: 'tunnel',
|
|
82
|
+
tunnelPid,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
38
85
|
return {
|
|
39
86
|
cdp,
|
|
40
87
|
port: localPort,
|
|
41
|
-
pid:
|
|
88
|
+
pid: tunnelPid,
|
|
42
89
|
cleanup: () => {
|
|
43
90
|
cdp.close();
|
|
44
91
|
tunnel.kill();
|
|
92
|
+
clearProfileRuntime(profile.name);
|
|
45
93
|
},
|
|
46
94
|
};
|
|
47
95
|
}
|
|
@@ -164,3 +212,30 @@ function runSSHCommand(user, host, cmd) {
|
|
|
164
212
|
function sleep(ms) {
|
|
165
213
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
166
214
|
}
|
|
215
|
+
/**
|
|
216
|
+
* Identify whether a pid listening on our target local port is an SSH
|
|
217
|
+
* tunnel WE would have spawned for `host:remotePort`. Used so that two
|
|
218
|
+
* agents-browser invocations of the same SSH profile share a tunnel
|
|
219
|
+
* rather than failing the second one with "port in use".
|
|
220
|
+
*
|
|
221
|
+
* Best-effort match against the ssh -L command line via `ps`. If we
|
|
222
|
+
* can't read the cmd or the args don't look like ours, treat as not-ours.
|
|
223
|
+
*/
|
|
224
|
+
function isOwnTunnel(pid, host, remotePort) {
|
|
225
|
+
try {
|
|
226
|
+
const out = execSync(`ps -p ${pid} -o command=`, {
|
|
227
|
+
encoding: 'utf-8',
|
|
228
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
229
|
+
}).toString().trim();
|
|
230
|
+
if (!out.startsWith('ssh'))
|
|
231
|
+
return false;
|
|
232
|
+
if (!out.includes(host))
|
|
233
|
+
return false;
|
|
234
|
+
if (!out.includes(`:${remotePort}`) && !out.includes(`:127.0.0.1:${remotePort}`))
|
|
235
|
+
return false;
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
}
|